Initial commit.
commit
3a40b0f22c
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
|
@ -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=
|
|
@ -0,0 +1,5 @@
|
|||
go.mod
|
||||
go.sum
|
||||
*.sh
|
||||
.travis.*
|
||||
.gitignore
|
|
@ -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
|
|
@ -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
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package authcache
|
||||
|
||||
import "golang.org/x/oauth2"
|
||||
|
||||
type Cache interface {
|
||||
Fetch() (*oauth2.Token, error)
|
||||
Persist(*oauth2.Token) error
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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,
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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"
|
Loading…
Reference in New Issue