From 945fb155b4aea9943143caa768d79d381995af1d Mon Sep 17 00:00:00 2001 From: Knut Ahlers Date: Wed, 12 Jun 2024 21:26:39 +0200 Subject: [PATCH] Add new editor token generator in order not to throw around Twitch tokens and have tokens expire Signed-off-by: Knut Ahlers --- authBackends.go | 18 ++- authMiddleware.go | 5 +- configEditor_global.go | 41 ++++++ go.mod | 1 + go.sum | 35 +---- internal/service/editortoken/editortoken.go | 135 ++++++++++++++++++ .../service/editortoken/editortoken_test.go | 55 +++++++ main.go | 14 +- 8 files changed, 268 insertions(+), 36 deletions(-) create mode 100644 internal/service/editortoken/editortoken.go create mode 100644 internal/service/editortoken/editortoken_test.go diff --git a/authBackends.go b/authBackends.go index 10e21d6..8264ef8 100644 --- a/authBackends.go +++ b/authBackends.go @@ -13,7 +13,7 @@ import ( const internalTokenAuthCacheExpiry = 5 * time.Minute -func authBackendInternalToken(token string) (modules []string, expiresAt time.Time, err error) { +func authBackendInternalAppToken(token string) (modules []string, expiresAt time.Time, err error) { for _, auth := range config.AuthTokens { if auth.validate(token) != nil { continue @@ -26,6 +26,22 @@ func authBackendInternalToken(token string) (modules []string, expiresAt time.Ti return nil, time.Time{}, authcache.ErrUnauthorized } +func authBackendInternalEditorToken(token string) ([]string, time.Time, error) { + id, user, expiresAt, err := editorTokenService.ValidateLoginToken(token) + if err != nil { + // None of our tokens: Nay. + return nil, time.Time{}, authcache.ErrUnauthorized + } + + if !str.StringInSlice(user, config.BotEditors) && !str.StringInSlice(id, config.BotEditors) { + // That user is none of our editors: Deny access + return nil, time.Time{}, authcache.ErrUnauthorized + } + + // Editors have full access: Return module "*" + return []string{"*"}, expiresAt, nil +} + func authBackendTwitchToken(token string) (modules []string, expiresAt time.Time, err error) { tc := twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, token, "") diff --git a/authMiddleware.go b/authMiddleware.go index d2090c8..8f70411 100644 --- a/authMiddleware.go +++ b/authMiddleware.go @@ -66,8 +66,11 @@ func writeAuthMiddleware(h http.Handler, module string) http.Handler { case strings.EqualFold(tokenType, "token"): // This is perfect: `Authorization: Token tokenhere` + case strings.EqualFold(tokenType, "bearer"): + // This is perfect: `Authorization: Bearer tokenhere` + default: - // That was unexpected: `Authorization: Bearer tokenhere` or similar + // That was unexpected http.Error(w, "invalid token type", http.StatusForbidden) return } diff --git a/configEditor_global.go b/configEditor_global.go index fc14e3c..b1f7dd6 100644 --- a/configEditor_global.go +++ b/configEditor_global.go @@ -8,6 +8,7 @@ import ( log "github.com/sirupsen/logrus" + "github.com/Luzifer/twitch-bot/v3/pkg/twitch" "github.com/Luzifer/twitch-bot/v3/plugins" ) @@ -27,6 +28,15 @@ func registerEditorGlobalMethods() { Path: "/actions", ResponseType: plugins.HTTPRouteResponseTypeJSON, }, + { + Description: "Exchanges the Twitch token against an internal Bearer token", + HandlerFunc: configEditorGlobalLogin, + Method: http.MethodPost, + Module: moduleConfigEditor, + Name: "Authorize on Config-Editor", + Path: "/login", + ResponseType: plugins.HTTPRouteResponseTypeJSON, + }, { Description: "Returns all available modules for auth", HandlerFunc: configEditorGlobalGetModules, @@ -154,6 +164,37 @@ func configEditorGlobalGetUser(w http.ResponseWriter, r *http.Request) { } } +func configEditorGlobalLogin(w http.ResponseWriter, r *http.Request) { + var payload struct { + Token string `json:"token"` + } + + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + tc := twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, payload.Token, "") + id, user, err := tc.GetAuthorizedUser(r.Context()) + if err != nil { + http.Error(w, "access denied", http.StatusUnauthorized) + return + } + + tok, expiresAt, err := editorTokenService.CreateLoginToken(id, user) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if err := json.NewEncoder(w).Encode(map[string]any{ + "expiresAt": expiresAt, + "token": tok, + }); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + func configEditorGlobalSubscribe(w http.ResponseWriter, r *http.Request) { conn, err := upgrader.Upgrade(w, r, nil) if err != nil { diff --git a/go.mod b/go.mod index 37eabcf..0b94e12 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/go-sql-driver/mysql v1.8.1 github.com/gofrs/uuid v4.4.0+incompatible github.com/gofrs/uuid/v3 v3.1.2 + github.com/golang-jwt/jwt/v5 v5.2.1 github.com/gorilla/mux v1.8.1 github.com/gorilla/websocket v1.5.1 github.com/itchyny/gojq v0.12.15 diff --git a/go.sum b/go.sum index 84d1026..2b5d15d 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,6 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/Luzifer/go-openssl/v4 v4.2.2 h1:wKF/GhSKGJtHFQYTkN61wXig7mPvDj/oPpW6MmnBpjc= github.com/Luzifer/go-openssl/v4 v4.2.2/go.mod h1:+kAwI4NpyYXoWil85gKSCEJNoCQlMeFikEMn2f+5ffc= -github.com/Luzifer/go_helpers/v2 v2.24.0 h1:abACOhsn6a6c6X22jq42mZM1wuOM0Ihfa6yzssrjrOg= -github.com/Luzifer/go_helpers/v2 v2.24.0/go.mod h1:KSVUdAJAav5cWGyB5oKGxmC27HrKULVTOxwPS/Kr+pc= github.com/Luzifer/go_helpers/v2 v2.25.0 h1:k1J4gd1+BfuokTDoWgcgib9P5mdadjzKEgbtKSVe46k= github.com/Luzifer/go_helpers/v2 v2.25.0/go.mod h1:KSVUdAJAav5cWGyB5oKGxmC27HrKULVTOxwPS/Kr+pc= github.com/Luzifer/korvike/functions v1.0.1 h1:9O9PQL7O8J3nBwR4XLyx4COC430QbnvueM+itA2HEto= @@ -34,8 +32,6 @@ github.com/cenkalti/backoff/v3 v3.2.2/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4r github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= github.com/cloudflare/circl v1.3.8 h1:j+V8jJt09PoeMFIu2uh5JUyEaIHTXVOHslFoLNAKqwI= github.com/cloudflare/circl v1.3.8/go.mod h1:PDRU+oXvdD7KCtgKxW95M5Z8BpSCJXQORiZFnBQS5QU= -github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= -github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= github.com/cyphar/filepath-securejoin v0.2.5 h1:6iR5tXJ/e6tJZzzdMc1km3Sa7RRIVBKAK32O2s7AYfo= github.com/cyphar/filepath-securejoin v0.2.5/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -70,8 +66,6 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= github.com/go-git/go-git/v5 v5.12.0 h1:7Md+ndsjrzZxbddRDZjF14qK+NN56sy6wkqaVrjZtys= github.com/go-git/go-git/v5 v5.12.0/go.mod h1:FTM9VKtnI2m65hNI/TenDDDnUf2Q9FHnXYjuz9i5OEY= -github.com/go-jose/go-jose/v4 v4.0.1 h1:QVEPDE3OluqXBQZDcnNvQrInro2h0e4eqNbnZSWqS6U= -github.com/go-jose/go-jose/v4 v4.0.1/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk= github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= @@ -83,6 +77,8 @@ github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1 github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gofrs/uuid/v3 v3.1.2 h1:V3IBv1oU82x6YIr5txe3azVHgmOKYdyKQTowm9moBlY= github.com/gofrs/uuid/v3 v3.1.2/go.mod h1:xPwMqoocQ1L5G6pXX5BcE7N5jlzn2o19oqAKxwZW/kI= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= @@ -101,13 +97,10 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= -github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= -github.com/hashicorp/go-hclog v1.6.2 h1:NOtoftovWkDheyUM/8JW3QMiXyxJK3uHRK7wV04nD2I= -github.com/hashicorp/go-hclog v1.6.2/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M= -github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= github.com/hashicorp/go-retryablehttp v0.7.6 h1:TwRYfx2z2C4cLbXmT8I5PgP/xmuqASDyiVuGYfs9GZM= github.com/hashicorp/go-retryablehttp v0.7.6/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= @@ -233,8 +226,6 @@ golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0 golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= -golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= -golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -248,12 +239,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= -golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= -golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/oauth2 v0.19.0 h1:9+E/EZBCbTLNrbN35fHv/a/d/mOBatymz1zbtQrXpIg= -golang.org/x/oauth2 v0.19.0/go.mod h1:vYi7skDa1x015PmRRYZ7+s1cWyPgrPiSYRe4rnsexc8= golang.org/x/oauth2 v0.20.0 h1:4mQdhULixXKP1rwYBW0vAijoXnkTG0BLCDRzfe1idMo= golang.org/x/oauth2 v0.20.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -274,8 +261,6 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= -golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -283,9 +268,8 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= -golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q= -golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -293,8 +277,6 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -333,15 +315,12 @@ gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s= gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= modernc.org/cc/v4 v4.21.0 h1:D/gLKtcztomvWbsbvBKo3leKQv+86f+DdqEZBBXhnag= modernc.org/cc/v4 v4.21.0/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= -modernc.org/ccgo/v4 v4.17.2 h1:rg8qg9Rxq7AtL29N0Ar5LyNmH/fQGV0LhphfcTJ5zRQ= -modernc.org/ccgo/v4 v4.17.2/go.mod h1:1FCbAtWYJoKuc+AviS+dH+vGNtYmFJqBeRWjmnDWsIg= modernc.org/ccgo/v4 v4.17.3 h1:t2CQci84jnxKw3GGnHvjGKjiNZeZqyQx/023spkk4hU= +modernc.org/ccgo/v4 v4.17.3/go.mod h1:1FCbAtWYJoKuc+AviS+dH+vGNtYmFJqBeRWjmnDWsIg= modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= -modernc.org/libc v1.50.3 h1:rxS4sOeGFzwiuDShZh0agxIRJnan/8vLsBomE50+OT4= -modernc.org/libc v1.50.3/go.mod h1:ZkNjeLQOsIbpUQhrp7H6dQVuxXPsCZKjTb0/nE/jQjU= modernc.org/libc v1.50.5 h1:ZzeUd0dIc/sUtoPTCYIrgypkuzoGzNu6kbEWj2VuEmk= modernc.org/libc v1.50.5/go.mod h1:rhzrUx5oePTSTIzBgM0mTftwWHK8tiT9aNFUt1mldl0= modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= @@ -352,8 +331,6 @@ modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= -modernc.org/sqlite v1.29.8 h1:nGKglNx9K5v0As+zF0/Gcl1kMkmaU1XynYyq92PbsC8= -modernc.org/sqlite v1.29.8/go.mod h1:lQPm27iqa4UNZpmr4Aor0MH0HkCLbt1huYDfWylLZFk= modernc.org/sqlite v1.29.9 h1:9RhNMklxJs+1596GNuAX+O/6040bvOwacTxuFcRuQow= modernc.org/sqlite v1.29.9/go.mod h1:ItX2a1OVGgNsFh6Dv60JQvGfJfTPHPVpV6DF59akYOA= modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= diff --git a/internal/service/editortoken/editortoken.go b/internal/service/editortoken/editortoken.go new file mode 100644 index 0000000..6ae6ad1 --- /dev/null +++ b/internal/service/editortoken/editortoken.go @@ -0,0 +1,135 @@ +// Package editortoken utilizes JWT to create / validate a token for +// the frontend +package editortoken + +import ( + "crypto/ed25519" + "crypto/rand" + "errors" + "fmt" + "time" + + "github.com/Luzifer/twitch-bot/v3/pkg/database" + "github.com/golang-jwt/jwt/v5" +) + +const ( + coreMetaSigningKey = "editortoken:signing-key" + tokenValidity = 24 * time.Hour +) + +type ( + claims struct { + TwitchUser twitchUser `json:"twitchUser"` + + jwt.RegisteredClaims + } + + twitchUser struct { + ID string `json:"id"` + Name string `json:"name"` + } + + // Service manages the permission database + Service struct{ db database.Connector } +) + +// New creates a new Service on the given database +func New(db database.Connector) *Service { + return &Service{db} +} + +// CreateLoginToken packs user-id and user name into a JWT, signs it +// and returns the signed token +func (s Service) CreateLoginToken(id, user string) (token string, expiresAt time.Time, err error) { + cl := claims{ + TwitchUser: twitchUser{ + ID: id, + Name: user, + }, + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: "Twitch-Bot", + Subject: id, + Audience: []string{}, + ExpiresAt: jwt.NewNumericDate(time.Now().Add(tokenValidity)), + NotBefore: jwt.NewNumericDate(time.Now()), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + + tok := jwt.NewWithClaims(&jwt.SigningMethodEd25519{}, cl) + + priv, err := s.getSigningKey() + if err != nil { + return "", expiresAt, fmt.Errorf("getting signing key: %w", err) + } + + if token, err = tok.SignedString(priv); err != nil { + return "", expiresAt, fmt.Errorf("signing token: %w", err) + } + + return token, cl.ExpiresAt.Time, nil +} + +// ValidateLoginToken takes a token, validates it with the stored +// key and returns the twitch-id and the user-name from the token +func (s Service) ValidateLoginToken(token string) (id, user string, expiresAt time.Time, err error) { + var cl claims + + tok, err := jwt.ParseWithClaims(token, &cl, func(*jwt.Token) (any, error) { + priv, err := s.getSigningKey() + if err != nil { + return nil, fmt.Errorf("getting private key: %w", err) + } + + return priv.Public(), nil + }) + if err != nil { + // Something went wrong when parsing & validating + return "", "", expiresAt, fmt.Errorf("validating token: %w", err) + } + + if claims, ok := tok.Claims.(*claims); ok { + // We had no error and the claims are our claims + return claims.TwitchUser.ID, claims.TwitchUser.Name, claims.ExpiresAt.Time, nil + } + + // We had no error but were not able to convert the claims + return "", "", expiresAt, fmt.Errorf("unknown claims type") +} + +func (s Service) getSigningKey() (priv ed25519.PrivateKey, err error) { + err = s.db.ReadEncryptedCoreMeta(coreMetaSigningKey, &priv) + switch { + case err == nil: + // We read the previously generated key + return priv, nil + + case errors.Is(err, database.ErrCoreMetaNotFound): + // We don't have a key yet or the key was wiped for some reason, + // we generate a new one which automatically is stored for later + // retrieval. + if priv, err = s.generateSigningKey(); err != nil { + return nil, fmt.Errorf("creating signing key: %w", err) + } + + return priv, nil + + default: + // Something went wrong, bail. + return nil, fmt.Errorf("reading signing key: %w", err) + } +} + +func (s Service) generateSigningKey() (ed25519.PrivateKey, error) { + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("generating key: %w", err) + } + + if err = s.db.StoreEncryptedCoreMeta(coreMetaSigningKey, priv); err != nil { + return nil, fmt.Errorf("storing signing key: %w", err) + } + + return priv, nil +} diff --git a/internal/service/editortoken/editortoken_test.go b/internal/service/editortoken/editortoken_test.go new file mode 100644 index 0000000..95debbd --- /dev/null +++ b/internal/service/editortoken/editortoken_test.go @@ -0,0 +1,55 @@ +package editortoken + +import ( + "crypto/ed25519" + "testing" + "time" + + "github.com/Luzifer/twitch-bot/v3/pkg/database" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateToken(t *testing.T) { + dbc := database.GetTestDatabase(t) + s := New(dbc) + + // Fresh database, no key stored, the key should be generated and + // stored + pk1, err := s.getSigningKey() + require.NoError(t, err) + assert.IsType(t, ed25519.PrivateKey{}, pk1) + + // Now database should contain key + var dbpk ed25519.PrivateKey + err = dbc.ReadCoreMeta(coreMetaSigningKey, &dbpk) + require.Error(t, err, "Key must not be readable with plain func") + err = dbc.ReadEncryptedCoreMeta(coreMetaSigningKey, &dbpk) + require.NoError(t, err) + + // When fetching the key again it should be the same as before + pk2, err := s.getSigningKey() + require.NoError(t, err) + assert.Equal(t, pk1, pk2) + assert.Equal(t, dbpk, pk2) +} + +func TestTokenFlow(t *testing.T) { + dbc := database.GetTestDatabase(t) + s := New(dbc) + + var ( + id = "123456" + user = "example" + ) + + tok, expiresAt, err := s.CreateLoginToken(id, user) + require.NoError(t, err) + assert.True(t, expiresAt.After(time.Now().Add(tokenValidity-time.Minute))) + + tid, tuser, texpiresAt, err := s.ValidateLoginToken(tok) + require.NoError(t, err) + assert.Equal(t, id, tid) + assert.Equal(t, user, tuser) + assert.Equal(t, expiresAt, texpiresAt) +} diff --git a/main.go b/main.go index 9a4d286..3e95da2 100644 --- a/main.go +++ b/main.go @@ -24,6 +24,7 @@ import ( "github.com/Luzifer/twitch-bot/v3/internal/helpers" "github.com/Luzifer/twitch-bot/v3/internal/service/access" "github.com/Luzifer/twitch-bot/v3/internal/service/authcache" + "github.com/Luzifer/twitch-bot/v3/internal/service/editortoken" "github.com/Luzifer/twitch-bot/v3/internal/service/timer" "github.com/Luzifer/twitch-bot/v3/pkg/database" "github.com/Luzifer/twitch-bot/v3/pkg/twitch" @@ -68,10 +69,11 @@ var ( runID = uuid.Must(uuid.NewV4()).String() - db database.Connector - accessService *access.Service - authService *authcache.Service - timerService *timer.Service + db database.Connector + accessService *access.Service + authService *authcache.Service + editorTokenService *editortoken.Service + timerService *timer.Service twitchClient *twitch.Client @@ -136,11 +138,13 @@ func main() { } authService = authcache.New( - authBackendInternalToken, + authBackendInternalAppToken, + authBackendInternalEditorToken, authBackendTwitchToken, ) cronService = cron.New(cron.WithSeconds()) + editorTokenService = editortoken.New(db) if timerService, err = timer.New(db, cronService); err != nil { log.WithError(err).Fatal("applying timer migration")