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);