Initial commit.

main
Icedream 2022-08-17 03:34:18 +02:00
commit 3a40b0f22c
Signed by: icedream
GPG Key ID: 468BBEEBB9EC6AEA
17 changed files with 1389 additions and 0 deletions

213
cmd/test/main.go Normal file
View File

@ -0,0 +1,213 @@
package main
import (
"context"
"flag"
"io"
"mime"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"git.icedream.tech/icedream/oneandone-billing-mailer/pkg/api"
"git.icedream.tech/icedream/oneandone-billing-mailer/pkg/appcontext"
"git.icedream.tech/icedream/oneandone-billing-mailer/pkg/auth"
"git.icedream.tech/icedream/oneandone-billing-mailer/pkg/auth/authcache"
"git.icedream.tech/icedream/oneandone-billing-mailer/pkg/auth/authcache/fileauthcache"
"git.icedream.tech/icedream/oneandone-billing-mailer/pkg/backend"
"git.icedream.tech/icedream/oneandone-billing-mailer/pkg/environment"
"github.com/go-logr/logr"
"github.com/go-logr/zapr"
"go.uber.org/zap"
"golang.org/x/oauth2"
)
const (
// webviewDebug toggles the debug mode in the Webview library.
webviewDebug = false
// apiDebug toggles the debug mode in the OpenAPI client.
apiDebug = false
)
// dateFormat is the format used to pass dates to the 1&1 API
const dateFormat = "2006-01-02"
// main is the main entrypoint.
func main() {
ctx := context.Background()
// load logger
zapLogger, err := zap.NewDevelopment()
if err != nil {
os.Stderr.WriteString("Could not initialize zap logger\n")
os.Exit(1)
return
}
generalLog := zapr.NewLogger(zapLogger)
ctx = logr.NewContext(ctx, generalLog)
log := generalLog.WithName("app")
// parse cmdline
flag.Parse()
// initialize oauth stuff for API
oauth, err := auth.NewOAuthFromEnvironment(ctx)
if err != nil {
log.Error(err, "Failed to initialize central login helper")
return
}
// Try and get an initial token first
tokenCache := fileauthcache.NewFileAuthCache("token.json")
token, err := tokenCache.Fetch()
if err != nil {
log.Error(err, "Failed to fetch token from cache")
return
}
if token == nil {
// We need to fetch an initial token
tokenSource, err := oauth.TokenSource(nil, requestUserConsentFunc(ctx))
if err != nil {
log.Error(err, "Failed to set up OAuth token source")
return
}
cacheTokenSource := authcache.NewCacheTokenSource(ctx, tokenCache, tokenSource)
token, err = cacheTokenSource.Token()
if err != nil {
log.Error(err, "Failed to fetch initial token")
return
}
}
// Set up new token source only for refreshing
tokenSource, err := oauth.TokenSource(token, nil)
if err != nil {
log.Error(err, "Failed to set up OAuth token source")
return
}
cacheTokenSource := authcache.NewCacheTokenSource(ctx, tokenCache, tokenSource)
reuseTokenSource := oauth2.ReuseTokenSource(token, cacheTokenSource)
client := oauth2.NewClient(ctx, reuseTokenSource)
client.Transport = backend.BackendRequestTransport(client.Transport)
// MSSA invoice requests from here
appctx, err := appcontext.GetEnvironmentContext()
if err != nil {
log.Error(err, "Failed to fetch app env context")
return
}
mssaBaseURL, err := appctx.MSSA.BaseURL()
if err != nil {
log.Error(err, "Failed to parse MSSA base URL")
return
}
auth := context.WithValue(ctx, api.ContextOAuth2, reuseTokenSource)
apiConfig := api.NewConfiguration()
apiConfig.HTTPClient = client
apiConfig.Debug = apiDebug
apiConfig.UserAgent = environment.UserAgent()
apiClient := api.NewAPIClient(apiConfig)
now := time.Now()
thisMonthStartDate := now.
// go to first day of this month
AddDate(0, 0, 1-now.Day())
nextMonthStartDate := thisMonthStartDate.
// add 1 month
AddDate(0, 1, 0)
apiReq := apiClient.InvoicesApi.GetInvoicesInInterval(auth,
thisMonthStartDate.Format(dateFormat),
nextMonthStartDate.Format(dateFormat)).
PerPage(1)
apiResp, resp, err := apiReq.Page(1).Execute()
if err != nil {
panic(err)
}
if resp.StatusCode != http.StatusOK {
panic(resp.Status)
}
for _, invoice := range apiResp.GetInvoice() {
// skip entries without invoice document
doc := invoice.Links.InvoiceDocument
if doc == nil {
continue
}
ext := ".pdf"
if doc.Type != nil {
exts, err := mime.ExtensionsByType(*doc.Type)
if err != nil {
log.V(1).Error(err, "Failed to fetch extensions for type",
"type", *doc.Type)
} else if len(exts) > 0 {
ext = exts[0]
}
}
// make sure extension starts with dot
if !strings.HasPrefix(ext, ".") {
ext = "." + ext
}
title := "Rechnung"
if doc.Title != nil {
title = *doc.Title
}
fileName := filepath.Clean(title) + ext
u, err := url.Parse(doc.Href)
if err != nil {
log.V(1).Error(err, "Failed to parse invoice document hyperlink reference, skipping",
"url", doc.Href)
continue
}
u = mssaBaseURL.ResolveReference(u)
// open PDF on server
resp, err := apiClient.GetConfig().HTTPClient.Get(u.String())
if err != nil {
log.Error(err, "Invoice PDF download request failed, skipping",
"fileName", fileName)
continue
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
log.Error(err, "Unexpected status code for invoice PDF download request, skipping",
"fileName", fileName,
"statusCode", resp.StatusCode)
continue
}
// write PDF to file as we download
f, err := os.Create(fileName)
if err != nil {
resp.Body.Close()
log.Error(err, "Could not create invoice PDF",
"fileName", fileName)
continue
}
_, err = io.Copy(f, resp.Body)
resp.Body.Close()
if err != nil {
os.Remove(f.Name())
log.Error(err, "Could not write to invoice PDF",
"fileName", fileName)
}
err = f.Close()
if err != nil {
os.Remove(f.Name())
log.Error(err, "Could not complete writing to invoice PDF",
"fileName", fileName)
}
}
}

79
cmd/test/oauth.go Normal file
View File

@ -0,0 +1,79 @@
package main
import (
"context"
"errors"
"sync"
"github.com/go-logr/logr"
"github.com/webview/webview"
"golang.org/x/oauth2/authhandler"
)
func requestUserConsentFunc(ctx context.Context) authhandler.AuthorizationHandler {
return func(originalWebUrl string) (string, string, error) {
return requestUserConsent(ctx, originalWebUrl)
}
}
func requestUserConsent(ctx context.Context, originalWebUrl string) (code string, state string, err error) {
log := logr.FromContextOrDiscard(ctx).WithName("requestUserConsent")
log.Info("Asking for user consent",
"url", originalWebUrl)
proxyServer, err := NewOneAndOneLoginProxy(ctx, "localhost:0")
if err != nil {
log.Error(err, "Failed to set up local 1&1 login proxy")
return "", "", nil
}
go proxyServer.Listen()
defer proxyServer.Teardown()
webUrl, err := proxyServer.ConvertURLString(originalWebUrl)
if err != nil {
log.Error(err, "Failed to convert oauth init URL to proxy URL")
return "", "", nil
}
ctx, cancelFunc := context.WithCancel(ctx)
wg := new(sync.WaitGroup)
w := webview.New(webviewDebug)
defer w.Destroy()
w.SetTitle("1&1 Login")
w.SetSize(480, 480, webview.HintNone)
w.Navigate(webUrl.String())
wg.Add(1)
go func() {
defer wg.Done()
doneC := ctx.Done()
select {
case values := <-proxyServer.ResponseCodeC():
code = values.Get("code")
state = values.Get("state")
log.V(1).Info("Got login response",
"code", code,
"state", state)
w.Terminate()
case <-doneC:
return
}
}()
w.Run()
cancelFunc()
wg.Wait()
// Did webview get terminated before we got a code?
// That's the user canceling login.
if code == "" {
return "", "", errors.New("user canceled login")
}
return code, state, nil
}

298
cmd/test/server.go Normal file
View File

@ -0,0 +1,298 @@
package main
import (
"bytes"
"context"
"fmt"
"io"
"io/ioutil"
"net"
"net/http"
"net/http/cookiejar"
"net/url"
"strings"
"github.com/go-logr/logr"
"golang.org/x/net/publicsuffix"
)
type OneAndOneLoginProxy struct {
httpClient *http.Client
listener net.Listener
cookieJar http.CookieJar
responseCodeC chan url.Values
logger logr.Logger
}
func NewOneAndOneLoginProxy(
ctx context.Context,
listenAddr string,
) (*OneAndOneLoginProxy, error) {
// create temporary cookie jar for login flow
cookieJar, err := cookiejar.New(&cookiejar.Options{
PublicSuffixList: publicsuffix.List,
})
if err != nil {
return nil, err
}
// channel to return response code on
responseCodeC := make(chan url.Values, 1)
p := &OneAndOneLoginProxy{
cookieJar: cookieJar,
httpClient: &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
},
logger: logr.FromContextOrDiscard(ctx).WithName("OneAndOneLoginProxy"),
responseCodeC: responseCodeC,
}
if err := p.setupListener(listenAddr); err != nil {
return nil, err
}
return p, nil
}
func (p *OneAndOneLoginProxy) ResponseCodeC() <-chan url.Values {
return p.responseCodeC
}
func (p *OneAndOneLoginProxy) Host() string {
addr := p.listener.Addr().(*net.TCPAddr)
return net.JoinHostPort(
addr.IP.String(),
fmt.Sprintf("%d", addr.Port))
}
func (p *OneAndOneLoginProxy) setupListener(listenAddr string) error {
if len(listenAddr) == 0 {
// any random local port
listenAddr = "localhost:0"
}
listener, err := net.Listen("tcp", listenAddr)
if err != nil {
return err
}
p.listener = listener
p.logger.V(1).Info("Proxy listener set up",
"addr", p.listener.Addr())
return nil
}
func (p *OneAndOneLoginProxy) Teardown() error {
var err error
p.logger.V(2).Info("Teardown() called on proxy")
if p.listener != nil {
p.logger.V(1).Info("Tearing down proxy")
err = p.listener.Close()
p.listener = nil
}
return err
}
func (p *OneAndOneLoginProxy) Listen() error {
p.logger.V(2).Info("Listen() called on proxy")
p.logger.V(1).Info("Proxy now going to listen")
return http.Serve(p.listener, p)
}
func convertToProxyPath(u *url.URL) string {
// Enforce path at least containing /
if !strings.HasPrefix(u.Path, "/") {
u.Path = "/" + u.Path
}
return fmt.Sprintf("%s/%s%s", u.Scheme, u.Host, u.Path)
}
func (p *OneAndOneLoginProxy) ConvertURL(u *url.URL) *url.URL {
return &url.URL{
Scheme: "http",
Host: p.Host(),
Path: convertToProxyPath(u),
RawQuery: u.RawQuery,
RawFragment: u.RawFragment,
}
}
func (p *OneAndOneLoginProxy) ConvertURLString(uStr string) (*url.URL, error) {
u, err := url.Parse(uStr)
if err != nil {
return nil, err
}
return p.ConvertURL(u), nil
}
func (p *OneAndOneLoginProxy) decodeURL(u *url.URL) (proxyURL *url.URL) {
reqURI := strings.TrimPrefix(u.Path, "/")
reqURIParts := strings.SplitN(reqURI, "/", 3)
proxyScheme := strings.ToLower(reqURIParts[0])
if reqURIParts[0] != "http" && reqURIParts[0] != "https" {
// assume this is a URL relative to https://account.1und1.de
proxyURL = &url.URL{
Scheme: "https",
Host: "account.1und1.de",
Path: u.Path,
RawQuery: u.RawQuery,
}
} else {
// process as absolute URL
proxyURL = &url.URL{
Scheme: proxyScheme,
RawQuery: u.RawQuery,
}
if len(reqURIParts) >= 2 {
proxyURL.Host = reqURIParts[1]
}
if len(reqURIParts) > 2 {
proxyURL.Path = reqURIParts[2]
}
}
return proxyURL
}
func (p *OneAndOneLoginProxy) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// create a new url from the raw RequestURI sent by the client
// /{scheme}/{hostport}/{path/with/slashes} => {scheme}://{hostport}/{path/with/slashes}
proxyURL := p.decodeURL(req.URL)
// buffer body before forwarding or processing it further
body, err := ioutil.ReadAll(req.Body)
if err != nil {
p.logger.V(1).Error(err, "Failed to read request body",
"method", req.Method,
"url", req.URL)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// you can reassign the body if you need to parse it as multipart
req.Body = ioutil.NopCloser(bytes.NewReader(body))
// assemble real request
proxyReq, err := http.NewRequest(
req.Method,
proxyURL.String(),
bytes.NewReader(body))
if err != nil {
p.logger.V(1).Error(err, "Failed to create proxy request",
"method", req.Method,
"url", req.URL,
"proxyURL", proxyURL.String())
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// set headers removing everything that we set ourselves
proxyReq.Header = make(http.Header)
for key, values := range req.Header {
for i, value := range values {
switch http.CanonicalHeaderKey(key) {
case "Cookie": // we add this from our own jar
continue
case "Origin", "Referer":
u, err := url.Parse(value)
if err == nil {
realURL := p.decodeURL(u)
values[i] = realURL.String()
}
}
proxyReq.Header.Add(key, value)
}
}
// load cookies from jar to request
for _, c := range p.cookieJar.Cookies(proxyReq.URL) {
// p.logger.V(1).Info("Proxy sends cookie", "cookie", c.Name)
proxyReq.AddCookie(c)
}
resp, err := p.httpClient.Do(proxyReq)
if err != nil {
p.logger.V(1).Error(err, "Failed to do real request",
"method", req.Method,
"url", req.URL,
"proxyURL", proxyURL.String())
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
// store cookies from response to jar
p.cookieJar.SetCookies(proxyReq.URL, resp.Cookies())
// copy response headers
respondNoContent := false
for key, values := range resp.Header {
for _, value := range values {
switch http.CanonicalHeaderKey(key) {
case "Location":
u, err := url.Parse(value)
if err != nil {
break
}
if u.IsAbs() {
switch strings.ToLower(u.Scheme) {
case "com.oneandone.controlcenter.android":
// there's our oauth code, let's catch it before
// anything bad happens
p.logger.V(1).Info("Proxy found oauth response", "url", u.String())
q := u.Query()
// Special case for empty state where server
// inexplicably responds with "state=null" which causes
// 3LO state check in go oauth library to fail
if q.Get("state") == "null" {
q.Set("state", "")
}
p.responseCodeC <- q
respondNoContent = true
default:
u = p.ConvertURL(u)
value = u.String()
}
}
case "Set-Cookie":
// skip these headers as we already handled that ourselves
continue
}
w.Header().Add(key, value)
}
}
if respondNoContent {
p.logger.V(1).Info("Serving incoming request without content",
"method", req.Method,
"url", req.URL,
"proxyURL", proxyURL.String())
w.WriteHeader(http.StatusNoContent)
return
}
// copy response body
p.logger.V(1).Info("Serving incoming request",
"method", req.Method,
"url", req.URL,
"proxyURL", proxyURL.String(),
"statusCode", resp.StatusCode,
"status", resp.Status)
w.WriteHeader(resp.StatusCode)
contentType := resp.Header.Get("Content-Type")
switch {
case resp.Uncompressed &&
(strings.Contains(contentType, "application/json") ||
strings.Contains(contentType, "text/plain") ||
strings.Contains(contentType, "text/html")):
buf := new(bytes.Buffer)
io.Copy(buf, resp.Body)
bufStr := buf.String()
io.Copy(w, strings.NewReader(bufStr))
default:
io.Copy(w, resp.Body)
}
}

24
go.mod Normal file
View File

@ -0,0 +1,24 @@
module git.icedream.tech/icedream/oneandone-billing-mailer
go 1.19
require (
github.com/go-logr/logr v1.2.3
github.com/go-logr/zapr v1.2.3
go.uber.org/zap v1.19.0
)
require (
github.com/golang/protobuf v1.5.2 // indirect
go.uber.org/atomic v1.7.0 // indirect
go.uber.org/multierr v1.6.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.28.0 // indirect
)
require (
github.com/google/uuid v1.3.0
github.com/webview/webview v0.0.0-20220729131735-25e7f41b8bbf
golang.org/x/net v0.0.0-20220812174116-3211cb980234
golang.org/x/oauth2 v0.0.0-20220808172628-8227340efae7
)

75
go.sum Normal file
View File

@ -0,0 +1,75 @@
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0=
github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/zapr v1.2.3 h1:a9vnzlIBPQBBkeaR9IuMUfmVOrQlkoC4YfPoFkX3T7A=
github.com/go-logr/zapr v1.2.3/go.mod h1:eIauM6P8qSvTw5o2ez6UEAfGjQKrxQTl5EoK+Qa2oG4=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/webview/webview v0.0.0-20220729131735-25e7f41b8bbf h1:xGxamNQiDXDlPzCumBdLWflZDwruHkwJ1iORcpJmblk=
github.com/webview/webview v0.0.0-20220729131735-25e7f41b8bbf/go.mod h1:rpXAuuHgyEJb6kXcXldlkOjU6y4x+YcASKKXJNUhh0Y=
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/goleak v1.1.10 h1:z+mqJhf6ss6BSfSM671tgKyZBFPTTJM+HLxnhPC3wu0=
go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/zap v1.19.0 h1:mZQZefskPPCMIBCSEH0v2/iUqqLrYtaeqwD6FUGUnFE=
go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20220812174116-3211cb980234 h1:RDqmgfe7SvlMWoqC3xwQ2blLO3fcWcxMa3eBLRdRW7E=
golang.org/x/net v0.0.0-20220812174116-3211cb980234/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/oauth2 v0.0.0-20220808172628-8227340efae7 h1:dtndE8FcEta75/4kHF3AbpuWzV6f1LjnLrM4pe2SZrw=
golang.org/x/oauth2 v0.0.0-20220808172628-8227340efae7/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191108193012-7d206e10da11 h1:Yq9t9jnGoR+dBuitxdo9l6Q7xh/zOyNnYUtDKaQ3x0E=
golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -0,0 +1,5 @@
go.mod
go.sum
*.sh
.travis.*
.gitignore

3
pkg/api/gen_client.go Normal file
View File

@ -0,0 +1,3 @@
package api
//go:generate openapi-generator-cli generate -i ../../spec/openapi.yml --package-name $GOPACKAGE -g go --enable-post-process-file -p isGoSubmodule=true -p generateInterfaces=true -p withGoCodegenComment=true

View File

@ -0,0 +1,94 @@
package appcontext
import (
"encoding/json"
"errors"
"net/http"
"net/url"
"strconv"
"time"
"git.icedream.tech/icedream/oneandone-billing-mailer/pkg/backend"
"git.icedream.tech/icedream/oneandone-billing-mailer/pkg/environment"
mssahttp "git.icedream.tech/icedream/oneandone-billing-mailer/pkg/mssa/http"
)
type Entry struct {
BaseURI string `json:"baseURI"`
}
func (e *Entry) BaseURL() (*url.URL, error) {
return url.Parse(e.BaseURI)
}
type Config struct {
RefreshTokenRenewInterval time.Duration `json:"refreshTokenRenewInterval"`
}
func mustMakeURL(u string) *url.URL {
uo, err := url.Parse(u)
if err != nil {
panic(err)
}
return uo
}
var (
appcontext_uri_live = mustMakeURL("https://auth-proxy.1und1.de/appcontext/appcontext.json")
)
var instance *AppContext
func GetEnvironmentContext() (*AppContext, error) {
if instance == nil {
err := retrieveAppContextInternal()
if err != nil {
return nil, err
}
}
return instance, nil
}
type AppContext struct {
MSSA Entry `json:"MSSA"`
CentralLogin Entry `json:"CentralLogin"`
CentralLoginApp Entry `json:"CentralLoginApp"`
ChatServiceRest Entry `json:"ChatServiceRest"`
ChatServiceWebsocket Entry `json:"ChatServiceWebsocket"`
ChatServiceWebsocket2 Entry `json:"ChatServiceWebsocket2"`
DOPAS Entry `json:"DOPAS"`
Config Config `json:"Config"`
Links map[string]string `json:"Links"`
}
func retrieveAppContextInternal() error {
u := appcontext_uri_live
req, err := http.NewRequest("GET", u.String(), nil)
if err != nil {
return err
}
req.Header.Add("Accept", "application/json")
req.Header.Add("User-agent", environment.UserAgent())
backend.AddRequestHeadersForProxy(req)
client := mssahttp.GetDefaultClient()
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return errors.New("unexpected error code: " + strconv.Itoa(resp.StatusCode))
}
appContext := new(AppContext)
err = json.NewDecoder(resp.Body).Decode(appContext)
if err != nil {
return err
}
instance = appContext
return nil
}

View File

@ -0,0 +1,8 @@
package authcache
import "golang.org/x/oauth2"
type Cache interface {
Fetch() (*oauth2.Token, error)
Persist(*oauth2.Token) error
}

View File

@ -0,0 +1,67 @@
package fileauthcache
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"git.icedream.tech/icedream/oneandone-billing-mailer/pkg/auth/authcache"
"golang.org/x/oauth2"
)
type fileAuthCache struct {
filePath string
cacheLock sync.Mutex
}
func NewFileAuthCache(filePath string) authcache.Cache {
return &fileAuthCache{
filePath: filePath,
}
}
func (ts *fileAuthCache) Persist(token *oauth2.Token) (err error) {
ts.cacheLock.Lock()
defer ts.cacheLock.Unlock()
f, err := os.CreateTemp(filepath.Dir(ts.filePath), "")
if err != nil {
return err
}
if err = f.Chmod(0o600); err != nil {
f.Close()
return err
}
if err = json.NewEncoder(f).Encode(token); err != nil {
f.Close()
return err
}
if err = f.Close(); err != nil {
return err
}
err = os.Rename(f.Name(), ts.filePath)
return err
}
func (ts *fileAuthCache) Fetch() (token *oauth2.Token, err error) {
f, err := os.OpenFile(ts.filePath, os.O_RDONLY, 0o600)
if os.IsNotExist(err) {
// Treat as no existing token found
return nil, nil
}
if err != nil {
return nil, err
}
decodedToken := new(oauth2.Token)
if err = json.NewDecoder(f).Decode(decodedToken); err != nil {
return nil, err
}
return decodedToken, nil
}

View File

@ -0,0 +1,71 @@
package authcache
import (
"context"
"github.com/go-logr/logr"
"golang.org/x/oauth2"
)
type cacheTokenSource struct {
cache Cache
tokenSource oauth2.TokenSource
ctx context.Context
}
func NewCacheTokenSource(ctx context.Context, cache Cache, tokenSource oauth2.TokenSource) oauth2.TokenSource {
// Avoid dupe wrapping
if rts, ok := tokenSource.(*cacheTokenSource); ok {
return rts
}
return &cacheTokenSource{
cache: cache,
tokenSource: tokenSource,
ctx: ctx,
}
}
func (ts *cacheTokenSource) Token() (*oauth2.Token, error) {
log := logr.FromContextOrDiscard(ts.ctx).WithName("cacheTokenSource")
token, err := ts.cache.Fetch()
if err != nil {
log.V(1).Info("Failed to fetch token", "err", err.Error())
return nil, err
}
oldToken := token
// Do we have a cached token?
if token == nil || token.RefreshToken == "" {
// We are starting fresh
log.V(1).Info("No existing token, requesting fresh one")
newToken, err := ts.tokenSource.Token()
if err != nil {
log.V(1).Info("Failed to request fresh token", "err", err.Error())
return nil, err
}
token = newToken
} else if !token.Valid() /*|| token.Expiry.Add(-5*time.Minute).Before(time.Now())*/ {
// Our existing token has expired, refresh
log.V(1).Info("Existing token has expired or is about to expire, requesting refresh")
newToken, err := ts.tokenSource.Token()
if err != nil {
log.V(1).Info("Failed to refresh token", "err", err.Error())
return nil, err
}
token = newToken
}
// Persist token if changed/fresh
if oldToken == nil || oldToken.AccessToken != token.AccessToken {
log.V(1).Info("Persisting new token")
if err := ts.cache.Persist(token); err != nil {
return nil, err
}
}
log.V(1).Info("Returning token")
return token, nil
}

View File

@ -0,0 +1,43 @@
package auth
import (
"encoding/base64"
"net/http"
"net/url"
"time"
_ "github.com/paulrosania/go-charset/data"
"git.icedream.tech/icedream/oneandone-billing-mailer/pkg/appcontext"
"git.icedream.tech/icedream/oneandone-billing-mailer/pkg/environment"
)
const (
client_id = "access.mobile.app"
client_secret_live = "Yi79C2YG2CJYH!U9TXPRpXRciyhApr"
redirect_url = "com.oneandone.controlcenter.android://oauth"
)
var urlEncodingWithoutPadding = base64.URLEncoding.WithPadding(base64.NoPadding)
type CentralLoginHelper struct {
baseUri *url.URL
}
func NewCentralLoginHelper() (*CentralLoginHelper, error) {
ctx, err := appcontext.GetEnvironmentContext()
if err != nil {
return nil, err
}
baseURL, err := ctx.CentralLogin.BaseURL()
if err != nil {
return nil, err
}
return &CentralLoginHelper{
baseUri: baseURL,
}, nil
}
func clientSecretForEnvironment() string {
return client_secret_live
}

114
pkg/auth/oauth.go Normal file
View File

@ -0,0 +1,114 @@
package auth
import (
"context"
"crypto/sha256"
"net/url"
"git.icedream.tech/icedream/oneandone-billing-mailer/pkg/appcontext"
"github.com/go-logr/logr"
"github.com/google/uuid"
"golang.org/x/oauth2"
"golang.org/x/oauth2/authhandler"
)
type OAuth struct {
config *oauth2.Config
ctx context.Context
pkceKey string
}
func NewOAuth(ctx context.Context, clientID string, clientSecret string, baseUri *url.URL) *OAuth {
endpoint := oauth2.Endpoint{
AuthURL: baseUri.ResolveReference(&url.URL{Path: "auth/init"}).String(),
TokenURL: baseUri.ResolveReference(&url.URL{Path: "token"}).String(),
}
config := &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
Endpoint: endpoint,
RedirectURL: redirect_url,
Scopes: []string{"openid", "account_otk"},
}
return &OAuth{
ctx: ctx,
config: config,
}
}
func NewOAuthFromEnvironment(ctx context.Context) (*OAuth, error) {
appctx, err := appcontext.GetEnvironmentContext()
if err != nil {
return nil, err
}
baseURL, err := appctx.CentralLogin.BaseURL()
if err != nil {
return nil, err
}
clientID := client_id
clientSecret := clientSecretForEnvironment()
return NewOAuth(ctx, clientID, clientSecret, baseURL), nil
}
func (h *OAuth) randomKey() (string, error) {
pkceKeyUUID, err := uuid.NewRandom()
if err != nil {
return "", err
}
pkceKey := pkceKeyUUID.String()
return pkceKey, nil
}
func (h *OAuth) TokenSource(t *oauth2.Token, authHandler authhandler.AuthorizationHandler) (oauth2.TokenSource, error) {
log := logr.FromContextOrDiscard(h.ctx).WithName("OAuth")
log.V(2).Info("TokenSource was called")
if t != nil && len(t.RefreshToken) > 0 {
// Use a refresher based on given token's refresh token instead
return h.config.TokenSource(h.ctx, t), nil
}
// Generate state.
//
// Note that the Control Center app does not even pass a state at all, but
// the oauth server properly implements handling it.
//state, err := h.randomKey()
//if err != nil {
// return nil, err
//}
state := ""
// Generate pkce key (which becomes our code verifier)
if len(h.pkceKey) == 0 {
pkceKey, err := h.randomKey()
if err != nil {
return nil, err
}
h.pkceKey = pkceKey
}
// Generate pkce key digest (which becomes our code challenge)
messageDigest := sha256.New()
messageDigest.Write([]byte(h.pkceKey))
messageDigestResult := messageDigest.Sum(nil)
messageDigestResultB64 := urlEncodingWithoutPadding.EncodeToString(messageDigestResult)
pkce := &authhandler.PKCEParams{
Challenge: messageDigestResultB64,
ChallengeMethod: "S256",
Verifier: h.pkceKey,
}
tok := authhandler.TokenSourceWithPKCE(
context.Background(),
h.config,
state,
authHandler,
pkce)
return tok, nil
}

29
pkg/backend/request.go Normal file
View File

@ -0,0 +1,29 @@
package backend
import "net/http"
func AddRequestHeadersForProxy(req *http.Request) {
req.Header.Set("Connection", "Keep-Alive")
req.Header.Set("Accept-Language", "de-DE")
req.Header.Set("Referer", "https://control-center-app.1und1.de")
}
type backendRequestTransport struct {
underlyingTransport http.RoundTripper
}
func (t *backendRequestTransport) RoundTrip(req *http.Request) (*http.Response, error) {
nreq := new(http.Request)
*nreq = *req
AddRequestHeadersForProxy(nreq)
return t.underlyingTransport.RoundTrip(nreq)
}
func BackendRequestTransport(t http.RoundTripper) http.RoundTripper {
if t == nil {
t = http.DefaultTransport
}
return &backendRequestTransport{
underlyingTransport: t,
}
}

View File

@ -0,0 +1,23 @@
package environment
import "fmt"
type Environment int
const (
LIVE
)
var (
CurrentEnvironment = LIVE
versionName = ""
versionRelease = ""
)
const (
USER_AGENT_TEMPLATE = "cc-app/%s Android/%s"
)
func UserAgent() string {
return fmt.Sprintf(USER_AGENT_TEMPLATE, versionName, versionRelease)
}

View File

@ -0,0 +1,30 @@
package mssahttp
import (
"net/http"
"time"
"git.icedream.tech/icedream/oneandone-billing-mailer/pkg/environment"
)
const (
connection_timeout = 30 * time.Second
)
type uaSetterTransport struct{}
func (t *uaSetterTransport) RoundTrip(req *http.Request) (*http.Response, error) {
actualReq := new(http.Request)
*actualReq = *req
actualReq.Header.Set("User-agent", environment.UserAgent())
return http.DefaultTransport.RoundTrip(actualReq)
}
var defaultClient = &http.Client{
Timeout: connection_timeout,
Transport: &uaSetterTransport{},
}
func GetDefaultClient() *http.Client {
return defaultClient
}

213
spec/openapi.yml Normal file
View File

@ -0,0 +1,213 @@
openapi: 3.0.3
info:
title: 1&1 Authenticated API
version: "1.0.0"
servers:
- url: https://auth-proxy.1und1.de/
description: Live
tags:
- name: Invoices
description: Invoice related requests
components:
parameters:
page:
name: page
in: query
schema:
type: integer
per_page:
name: per_page
in: query
schema:
type: integer
interval_from:
name: interval_from
description: The start of the date range for which to list invoices.
in: path
required: true
schema:
type: string
format: date
interval_to:
name: interval_to
description: The end of the date range for which to list invoices.
in: path
required: true
schema:
type: string
format: date
schemas:
Link:
type: object
description: Represents a link.
required:
- href
properties:
href:
type: string
description: Hyperlink reference to the linked resource.
title:
type: string
description: Title for the linked resource.
type:
type: string
description: MIME type of the linke resource.
format: mime
Invoice:
type: object
description: Represents a customer invoice.
properties:
links:
description: Linked resources.
type: object
properties:
self:
$ref: "#/components/schemas/Link"
detail:
$ref: "#/components/schemas/Link"
faq:
$ref: "#/components/schemas/Link"
contract:
$ref: "#/components/schemas/Link"
invoice-document:
$ref: "#/components/schemas/Link"
type:
description: Invoice type.
type: string
enum:
- SINGLE
invoiceId:
description: Invoice ID.
type: string
parts:
description: Invoice parts.
type: array
items:
type: object
required:
- id
- contractId
- prouct
- gross
- net
- currency
- invoicePositions
properties:
id:
description: Invoice part ID.
type: integer
format: int64
contractId:
description: Contract ID.
type: integer
format: int64
product:
description: Product name.
type: string
gross:
description: Gross price.
type: number
net:
description: Net price.
type: number
currency:
description: 3 letter currency code as defined by ISO-4217
type: string
format: iso-4217
example: EUR
# invoicePositions:
# type: array
# items:
# type: any
responses:
Error:
description: Error.
content:
text/plain:
schema:
type: string
example: 401 Unauthorized
InvoiceList:
description: List of invoices.
content:
application/json:
schema:
type: object
required:
- links
- invoice
properties:
links:
description: Linked resources.
type: object
properties:
self:
$ref: "#/components/schemas/Link"
next:
$ref: "#/components/schemas/Link"
prev:
$ref: "#/components/schemas/Link"
aggregations:
$ref: "#/components/schemas/Link"
contractaggregations:
$ref: "#/components/schemas/Link"
balances:
$ref: "#/components/schemas/Link"
invoice:
type: array
description: List of found invoices.
items:
$ref: "#/components/schemas/Invoice"
securitySchemes:
oauth1and1:
type: oauth2
description: |
This API uses 3-legged OAuth.
flows:
authorizationCode:
authorizationUrl: https://auth.1und1.de/1.0/oauth/auth/init
tokenUrl: https://auth.1und1.de/1.0/oauth/token
scopes:
openid: openid
account_otk: Account OTK
security:
- oauth1and1:
- openid
- account_otk
paths:
/mssa/invoices:
get:
operationId: getInvoices
summary: Gets list of customer invoices
tags:
- Invoices
parameters:
- $ref: "#/components/parameters/page"
- $ref: "#/components/parameters/per_page"
responses:
default:
$ref: "#/components/responses/Error"
"200":
$ref: "#/components/responses/InvoiceList"
/mssa/invoices;interval={interval_from}_{interval_to}:
get:
operationId: getInvoicesInInterval
summary: Get list of customer invoices for given interval.
tags:
- Invoices
parameters:
- $ref: "#/components/parameters/interval_from"
- $ref: "#/components/parameters/interval_to"
- $ref: "#/components/parameters/page"
- $ref: "#/components/parameters/per_page"
responses:
default:
$ref: "#/components/responses/Error"
"200":
$ref: "#/components/responses/InvoiceList"