diff --git a/database/db-types.go b/database/db-types.go index 06c328c..6ce6c53 100644 --- a/database/db-types.go +++ b/database/db-types.go @@ -15,8 +15,8 @@ type User struct { } type ClientInfoDbOutput struct { - Sub, Name, Secret, Domain, Owner string - Public, SSO, Active bool + Sub, Name, Secret, Domain, Owner, Perms string + Public, SSO, Active bool } var _ oauth2.ClientInfo = &ClientInfoDbOutput{} @@ -37,3 +37,6 @@ func (c *ClientInfoDbOutput) IsSSO() bool { return c.SSO } // IsActive is an extra field for the app manager to get the active state func (c *ClientInfoDbOutput) IsActive() bool { return c.Active } + +// UsePerms is an extra field for the userinfo handler to return user permissions matching the requested values +func (c *ClientInfoDbOutput) UsePerms() string { return c.Perms } diff --git a/database/init.sql b/database/init.sql index 083ecde..2be31a7 100644 --- a/database/init.sql +++ b/database/init.sql @@ -18,6 +18,7 @@ CREATE TABLE IF NOT EXISTS client_store secret TEXT UNIQUE NOT NULL, domain TEXT NOT NULL, owner TEXT NOT NULL, + perms TEXT NOT NULL, public INTEGER, sso INTEGER, active INTEGER DEFAULT 1, diff --git a/database/tx.go b/database/tx.go index b7f2a94..68cf6d0 100644 --- a/database/tx.go +++ b/database/tx.go @@ -6,6 +6,7 @@ import ( "github.com/1f349/lavender/password" "github.com/go-oauth2/oauth2/v4" "github.com/google/uuid" + "log" "time" ) @@ -65,8 +66,8 @@ func (t *Tx) GetUserEmail(sub string) (string, error) { func (t *Tx) GetClientInfo(sub string) (oauth2.ClientInfo, error) { var u ClientInfoDbOutput - row := t.tx.QueryRow(`SELECT secret, name, domain, public, sso, active FROM client_store WHERE subject = ? LIMIT 1`, sub) - err := row.Scan(&u.Secret, &u.Name, &u.Domain, &u.Public, &u.SSO, &u.Active) + row := t.tx.QueryRow(`SELECT secret, name, domain, perms, public, sso, active FROM client_store WHERE subject = ? LIMIT 1`, sub) + err := row.Scan(&u.Secret, &u.Name, &u.Domain, &u.Perms, &u.Public, &u.SSO, &u.Active) u.Owner = sub if !u.Active { return nil, fmt.Errorf("client is not active") @@ -76,14 +77,14 @@ func (t *Tx) GetClientInfo(sub string) (oauth2.ClientInfo, error) { func (t *Tx) GetAppList(owner string, admin bool, offset int) ([]ClientInfoDbOutput, error) { var u []ClientInfoDbOutput - row, err := t.tx.Query(`SELECT subject, name, domain, owner, public, sso, active FROM client_store WHERE owner = ? OR ? = 1 LIMIT 25 OFFSET ?`, owner, admin, offset) + row, err := t.tx.Query(`SELECT subject, name, domain, owner, perms, public, sso, active FROM client_store WHERE owner = ? OR ? = 1 LIMIT 25 OFFSET ?`, owner, admin, offset) if err != nil { return nil, err } defer row.Close() for row.Next() { var a ClientInfoDbOutput - err := row.Scan(&a.Sub, &a.Name, &a.Domain, &a.Owner, &a.Public, &a.SSO, &a.Active) + err := row.Scan(&a.Sub, &a.Name, &a.Domain, &a.Owner, &a.Perms, &a.Public, &a.SSO, &a.Active) if err != nil { return nil, err } @@ -92,18 +93,19 @@ func (t *Tx) GetAppList(owner string, admin bool, offset int) ([]ClientInfoDbOut return u, row.Err() } -func (t *Tx) InsertClientApp(name, domain string, public, sso, active bool, owner string) error { +func (t *Tx) InsertClientApp(name, domain, owner, perms string, public, sso, active bool) error { u := uuid.New() secret, err := password.GenerateApiSecret(70) if err != nil { return err } - _, err = t.tx.Exec(`INSERT INTO client_store (subject, name, secret, domain, owner, public, sso, active) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, u.String(), name, secret, domain, owner, public, sso, active) + _, err = t.tx.Exec(`INSERT INTO client_store (subject, name, secret, domain, owner, perms, public, sso, active) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, u.String(), name, secret, domain, owner, perms, public, sso, active) return err } -func (t *Tx) UpdateClientApp(subject uuid.UUID, owner string, name, domain string, public, sso, active bool) error { - _, err := t.tx.Exec(`UPDATE client_store SET name = ?, domain = ?, public = ?, sso = ?, active = ? WHERE subject = ? AND owner = ?`, name, domain, public, sso, active, subject.String(), owner) +func (t *Tx) UpdateClientApp(subject uuid.UUID, owner, name, domain, perms string, hasPerms, public, sso, active bool) error { + log.Println(hasPerms, perms) + _, err := t.tx.Exec(`UPDATE client_store SET name = ?, domain = ?, perms = CASE WHEN ? = true THEN ? ELSE perms END, public = ?, sso = ?, active = ? WHERE subject = ? AND owner = ?`, name, domain, hasPerms, perms, public, sso, active, subject.String(), owner) return err } diff --git a/go.mod b/go.mod index bfd0121..b1249d8 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/MrMelon54/exit-reload v0.0.1 github.com/go-oauth2/oauth2/v4 v4.5.2 github.com/go-session/session v3.1.2+incompatible + github.com/golang-jwt/jwt/v4 v4.5.0 github.com/google/subcommands v1.2.0 github.com/google/uuid v1.6.0 github.com/julienschmidt/httprouter v1.3.0 @@ -20,9 +21,9 @@ require ( require ( github.com/MrMelon54/rescheduler v0.0.2 // indirect + github.com/becheran/wildmatch-go v1.0.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/golang-jwt/jwt v3.2.2+incompatible // indirect - github.com/golang-jwt/jwt/v4 v4.5.0 // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/kr/text v0.2.0 // indirect github.com/pkg/errors v0.9.1 // indirect diff --git a/go.sum b/go.sum index 3aa6a67..a872c1b 100644 --- a/go.sum +++ b/go.sum @@ -15,6 +15,8 @@ github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY= github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/becheran/wildmatch-go v1.0.0 h1:mE3dGGkTmpKtT4Z+88t8RStG40yN9T+kFEGj2PZFSzA= +github.com/becheran/wildmatch-go v1.0.0/go.mod h1:gbMvj0NtVdJ15Mg/mH9uxk2R1QCistMyU7d9KFzroX4= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 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= diff --git a/pages/manage-apps.go.html b/pages/manage-apps.go.html index c508ceb..c3c9446 100644 --- a/pages/manage-apps.go.html +++ b/pages/manage-apps.go.html @@ -58,6 +58,12 @@ + {{if .IsAdmin}} +
+ + +
+ {{end}}
@@ -86,6 +92,7 @@ ID Name Domain + Perms SSO Active Owner @@ -98,6 +105,7 @@ {{.Sub}} {{.Name}} {{.Domain}} + {{.Perms}} {{.SSO}} {{.Active}} {{.Owner}} @@ -132,6 +140,12 @@ + {{if .IsAdmin}} +
+ + +
+ {{end}}
diff --git a/server/auth.go b/server/auth.go index d85ffb5..399c872 100644 --- a/server/auth.go +++ b/server/auth.go @@ -69,7 +69,7 @@ func (h *HttpServer) OptionalAuthentication(next UserHandler) httprouter.Handle } if auth.IsGuest() { // if this fails internally it just sees the user as logged out - h.readLoginDataCookie(rw, req, &auth) + h.readLoginDataCookie(req, &auth) } next(rw, req, params, auth) } diff --git a/server/db.go b/server/db.go index 7b389be..316e28f 100644 --- a/server/db.go +++ b/server/db.go @@ -31,3 +31,13 @@ func (h *HttpServer) DbTx(rw http.ResponseWriter, action func(tx *database.Tx) e return false } + +func (h *HttpServer) DbTxRaw(action func(tx *database.Tx) error) bool { + return h.DbTx(&fakeRW{}, action) +} + +type fakeRW struct{} + +func (f *fakeRW) Header() http.Header { return http.Header{} } +func (f *fakeRW) Write(b []byte) (int, error) { return len(b), nil } +func (f *fakeRW) WriteHeader(statusCode int) {} diff --git a/server/home.go b/server/home.go index 62d6f16..2959a19 100644 --- a/server/home.go +++ b/server/home.go @@ -7,9 +7,8 @@ import ( "net/http" ) -func (h *HttpServer) Home(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { +func (h *HttpServer) Home(rw http.ResponseWriter, _ *http.Request, _ httprouter.Params, auth UserAuth) { rw.Header().Set("Content-Type", "text/html") - rw.WriteHeader(http.StatusOK) if auth.IsGuest() { pages.RenderPageTemplate(rw, "index-guest", map[string]any{ "ServiceName": h.conf.ServiceName, diff --git a/server/jwt.go b/server/jwt.go new file mode 100644 index 0000000..e6fc03d --- /dev/null +++ b/server/jwt.go @@ -0,0 +1,57 @@ +package server + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "github.com/1f349/lavender/database" + "github.com/1f349/mjwt" + "github.com/1f349/mjwt/auth" + "github.com/1f349/mjwt/claims" + "github.com/go-oauth2/oauth2/v4" + "github.com/golang-jwt/jwt/v4" + "github.com/google/uuid" + "strings" +) + +type JWTAccessGenerate struct { + signer mjwt.Signer + db *database.DB +} + +func NewJWTAccessGenerate(signer mjwt.Signer, db *database.DB) *JWTAccessGenerate { + return &JWTAccessGenerate{signer, db} +} + +var _ oauth2.AccessGenerate = &JWTAccessGenerate{} + +func (j *JWTAccessGenerate) Token(ctx context.Context, data *oauth2.GenerateBasic, isGenRefresh bool) (access, refresh string, err error) { + beginCtx, err := j.db.BeginCtx(ctx) + if err != nil { + return "", "", err + } + roles, err := beginCtx.GetUserRoles(data.UserID) + if err != nil { + return "", "", err + } + beginCtx.Rollback() + + ps := claims.NewPermStorage() + ForEachRole(data.Client.(interface{ UsePerms() string }).UsePerms(), func(role string) { + if HasRole(roles, role) { + ps.Set(role) + } + }) + + access, err = j.signer.GenerateJwt(data.UserID, "", jwt.ClaimStrings{data.TokenInfo.GetClientID()}, data.TokenInfo.GetAccessExpiresIn(), auth.AccessTokenClaims{ + Perms: ps, + }) + + if isGenRefresh { + t := uuid.NewHash(sha256.New(), uuid.New(), []byte(access), 5).String() + refresh = base64.URLEncoding.EncodeToString([]byte(t)) + refresh = strings.ToUpper(strings.TrimRight(refresh, "=")) + } + + return +} diff --git a/server/login.go b/server/login.go index e950fff..8ad9fb9 100644 --- a/server/login.go +++ b/server/login.go @@ -1,13 +1,12 @@ package server import ( - "bytes" "context" "crypto/rand" "crypto/rsa" "crypto/sha256" "database/sql" - "encoding/base64" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -129,7 +128,7 @@ func (h *HttpServer) loginCallback(rw http.ResponseWriter, req *http.Request, _ // only continues if the above tx succeeds auth.Data = sessionData - if auth.SaveSessionData() != nil { + if err := auth.SaveSessionData(); err != nil { http.Error(rw, "Failed to save session", http.StatusInternalServerError) return } @@ -140,8 +139,8 @@ func (h *HttpServer) loginCallback(rw http.ResponseWriter, req *http.Request, _ return } - if h.setLoginDataCookie(rw, auth.Data.ID, token) { - http.Error(rw, "Internal Server Error", http.StatusInternalServerError) + if h.setLoginDataCookie(rw, auth.Data.ID) { + http.Error(rw, "Failed to save login cookie", http.StatusInternalServerError) return } if flowState.redirect != "" { @@ -150,22 +149,15 @@ func (h *HttpServer) loginCallback(rw http.ResponseWriter, req *http.Request, _ h.SafeRedirect(rw, req) } -func (h *HttpServer) setLoginDataCookie(rw http.ResponseWriter, userId string, token *oauth2.Token) bool { - buf := new(bytes.Buffer) - buf.WriteString(userId) - buf.WriteByte(0) - err := json.NewEncoder(buf).Encode(token) +func (h *HttpServer) setLoginDataCookie(rw http.ResponseWriter, userId string) bool { + encData, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, h.signingKey.PublicKey(), []byte(userId), []byte("lavender-login-data")) if err != nil { return true } - encryptedData, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, h.signingKey.PublicKey(), buf.Bytes(), []byte("lavender-login-data")) - if err != nil { - return true - } - encryptedString := base64.RawStdEncoding.EncodeToString(encryptedData) + http.SetCookie(rw, &http.Cookie{ Name: "lavender-login-data", - Value: encryptedString, + Value: hex.EncodeToString(encData), Path: "/", Expires: time.Now().AddDate(0, 3, 0), Secure: true, @@ -174,30 +166,25 @@ func (h *HttpServer) setLoginDataCookie(rw http.ResponseWriter, userId string, t return false } -func (h *HttpServer) readLoginDataCookie(rw http.ResponseWriter, req *http.Request, u *UserAuth) { +func (h *HttpServer) readLoginDataCookie(req *http.Request, u *UserAuth) { loginCookie, err := req.Cookie("lavender-login-data") if err != nil { return } - decryptedBytes, err := base64.RawStdEncoding.DecodeString(loginCookie.Value) + hexData, err := hex.DecodeString(loginCookie.Value) if err != nil { return } - decryptedData, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, h.signingKey.PrivateKey(), decryptedBytes, []byte("lavender-login-data")) + decData, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, h.signingKey.PrivateKey(), hexData, []byte("lavender-login-data")) if err != nil { return } - buf := bytes.NewBuffer(decryptedData) - userId, err := buf.ReadString(0) - if err != nil { - return - } - userId = strings.TrimSuffix(userId, "\x00") - - var token *oauth2.Token - err = json.NewDecoder(buf).Decode(&token) - if err != nil { + userId := string(decData) + var token oauth2.Token + if h.DbTxRaw(func(tx *database.Tx) error { + return tx.GetUserToken(userId, &token.AccessToken, &token.RefreshToken, &token.Expiry) + }) { return } @@ -206,11 +193,7 @@ func (h *HttpServer) readLoginDataCookie(rw http.ResponseWriter, req *http.Reque return } - u.Data, err = h.fetchUserInfo(sso, token) - if err != nil { - http.Error(rw, "Failed to fetch user info", http.StatusInternalServerError) - return - } + u.Data, _ = h.fetchUserInfo(sso, &token) } func (h *HttpServer) fetchUserInfo(sso *issuer.WellKnownOIDC, token *oauth2.Token) (SessionData, error) { diff --git a/server/manage-apps.go b/server/manage-apps.go index f4b56ee..984816e 100644 --- a/server/manage-apps.go +++ b/server/manage-apps.go @@ -72,11 +72,12 @@ func (h *HttpServer) ManageAppsPost(rw http.ResponseWriter, req *http.Request, _ action := req.Form.Get("action") name := req.Form.Get("name") domain := req.Form.Get("domain") + hasPerms := req.Form.Has("perms") public := req.Form.Has("public") sso := req.Form.Has("sso") active := req.Form.Has("active") - if sso { + if sso || hasPerms { var roles string if h.DbTx(rw, func(tx *database.Tx) (err error) { roles, err = tx.GetUserRoles(auth.Data.ID) @@ -85,15 +86,19 @@ func (h *HttpServer) ManageAppsPost(rw http.ResponseWriter, req *http.Request, _ return } if !HasRole(roles, "lavender:admin") { - http.Error(rw, "400 Bad Request: Only admin users can create SSO client applications", http.StatusBadRequest) + http.Error(rw, "400 Bad Request: Only admin users can create SSO client applications or edit required permissions", http.StatusBadRequest) return } } + var perms string + if hasPerms { + perms = req.Form.Get("perms") + } switch action { case "create": if h.DbTx(rw, func(tx *database.Tx) error { - return tx.InsertClientApp(name, domain, public, sso, active, auth.Data.ID) + return tx.InsertClientApp(name, domain, auth.Data.ID, perms, public, sso, active) }) { return } @@ -103,7 +108,7 @@ func (h *HttpServer) ManageAppsPost(rw http.ResponseWriter, req *http.Request, _ if err != nil { return err } - return tx.UpdateClientApp(sub, auth.Data.ID, name, domain, public, sso, active) + return tx.UpdateClientApp(sub, auth.Data.ID, name, domain, perms, hasPerms, public, sso, active) }) { return } diff --git a/server/roles.go b/server/roles.go index 7dd2168..0d9af20 100644 --- a/server/roles.go +++ b/server/roles.go @@ -15,3 +15,11 @@ func HasRole(roles, test string) bool { } return false } + +func ForEachRole(roles string, next func(role string)) { + sc := bufio.NewScanner(strings.NewReader(roles)) + sc.Split(bufio.ScanWords) + for sc.Scan() { + next(sc.Text()) + } +} diff --git a/server/server.go b/server/server.go index 450977a..db74dee 100644 --- a/server/server.go +++ b/server/server.go @@ -14,13 +14,12 @@ import ( "github.com/1f349/lavender/theme" "github.com/1f349/mjwt" "github.com/go-oauth2/oauth2/v4/errors" - "github.com/go-oauth2/oauth2/v4/generates" "github.com/go-oauth2/oauth2/v4/manage" "github.com/go-oauth2/oauth2/v4/server" "github.com/go-oauth2/oauth2/v4/store" "github.com/go-session/session" "github.com/julienschmidt/httprouter" - oauth22 "golang.org/x/oauth2" + "golang.org/x/oauth2" "log" "net/http" "net/url" @@ -84,7 +83,7 @@ func NewHttpServer(conf Conf, db *database.DB, signingKey mjwt.Signer) *http.Ser oauthManager.SetAuthorizeCodeTokenCfg(manage.DefaultAuthorizeCodeTokenCfg) oauthManager.MustTokenStorage(store.NewMemoryTokenStore()) - oauthManager.MapAccessGenerate(generates.NewAccessGenerate()) + oauthManager.MapAccessGenerate(NewJWTAccessGenerate(hs.signingKey, db)) oauthManager.MapClientStorage(clientStore.New(db)) oauthSrv.SetResponseErrorHandler(func(re *errors.Response) { @@ -194,7 +193,7 @@ func NewHttpServer(conf Conf, db *database.DB, signingKey mjwt.Signer) *http.Ser return } - var clientToken oauth22.Token + var clientToken oauth2.Token if hs.DbTx(rw, func(tx *database.Tx) error { return tx.GetUserToken(userId, &clientToken.AccessToken, &clientToken.RefreshToken, &clientToken.Expiry) }) { diff --git a/test-client/index.html b/test-client/index.html index 8969f4d..62e7b7c 100644 --- a/test-client/index.html +++ b/test-client/index.html @@ -8,12 +8,28 @@ POP2.init(ssoService + "/authorize", "f4cdb93d-fe28-427b-b037-f03f44c86a16", "openid profile age", 500, 600); + window.addEventListener("load", function () { + doThisThing(false); + }) + function updateTokenInfo(data) { document.getElementById("someTextArea").textContent = JSON.stringify(data, null, 2); + POP2.getToken(function (x) { + document.getElementById("tokenValues").textContent = JSON.stringify(parseJwt(x), null, 2); + }); } - function doThisThing() { - POP2.clientRequest(ssoService + "/userinfo", {}, true).then(function (x) { + function parseJwt(token) { + const base64Url = token.split('.')[1]; + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); + const jsonPayload = decodeURIComponent(window.atob(base64).split('').map(function (c) { + return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); + }).join('')); + return JSON.parse(jsonPayload); + } + + function doThisThing(popup) { + POP2.clientRequest(ssoService + "/userinfo", {}, popup).then(function (x) { return x.json(); }).then(function (x) { updateTokenInfo(x); @@ -44,7 +60,7 @@
- +
diff --git a/test-client/pop2.js b/test-client/pop2.js index 52ab6a6..0220d39 100644 --- a/test-client/pop2.js +++ b/test-client/pop2.js @@ -104,14 +104,10 @@ callbackWaitForToken = undefined; } }, - // boolean, indicate logged in or not - isLoggedIn: function () { - return !!access_token; - }, // pass the access token to callback // if not logged in this triggers login popup; // use isLoggedIn to check login first to prevent popup blocker - getToken: function (callback) { + getToken: function (callback, popup = true) { if (!client_id || !redirect_uri || !scope) { alert('You need init() first. Check the program flow.'); return false; @@ -128,8 +124,10 @@ w_width, w_height ); + return false; } else { - return callback(access_token); + callback(access_token); + return true; } }, clientRequest: function (resource, options, refresh = false) { @@ -159,8 +157,10 @@ }); }; - if (!refresh) return sendRequest(); - else { + if (!refresh) { + if (!access_token) return Promise.reject("missing access token"); + return sendRequest(); + } else { return new Promise(function (res, rej) { sendRequest().then(function (x) { res(x);