Allow specific permissions to be sent to authorized clients and use JWT access tokens

This commit is contained in:
Melon 2024-02-10 16:23:50 +00:00
parent 05b19e6bf2
commit c6d64e5d81
Signed by: melon
GPG Key ID: 6C9D970C50D26A25
16 changed files with 167 additions and 67 deletions

View File

@ -15,7 +15,7 @@ type User struct {
}
type ClientInfoDbOutput struct {
Sub, Name, Secret, Domain, Owner string
Sub, Name, Secret, Domain, Owner, Perms string
Public, SSO, Active bool
}
@ -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 }

View File

@ -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,

View File

@ -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
}

3
go.mod
View File

@ -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

2
go.sum
View File

@ -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=

View File

@ -58,6 +58,12 @@
<label for="field_domain">Domain:</label>
<input type="text" name="domain" id="field_domain" value="{{.Edit.Domain}}" required/>
</div>
{{if .IsAdmin}}
<div>
<label for="field_perms">Perms:</label>
<input type="text" name="perms" id="field_perms" value="{{.Edit.Perms}}" required/>
</div>
{{end}}
<div>
<label for="field_public">Public: <input type="checkbox" name="public" id="field_public" {{if .Edit.Public}}checked{{end}}/></label>
</div>
@ -86,6 +92,7 @@
<th>ID</th>
<th>Name</th>
<th>Domain</th>
<th>Perms</th>
<th>SSO</th>
<th>Active</th>
<th>Owner</th>
@ -98,6 +105,7 @@
<td>{{.Sub}}</td>
<td>{{.Name}}</td>
<td>{{.Domain}}</td>
<td>{{.Perms}}</td>
<td>{{.SSO}}</td>
<td>{{.Active}}</td>
<td>{{.Owner}}</td>
@ -132,6 +140,12 @@
<label for="field_domain">Domain:</label>
<input type="text" name="domain" id="field_domain" required/>
</div>
{{if .IsAdmin}}
<div>
<label for="field_perms">Perms:</label>
<input type="text" name="perms" id="field_perms" required/>
</div>
{{end}}
<div>
<label for="field_public">Public: <input type="checkbox" name="public" id="field_public"/></label>
</div>

View File

@ -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)
}

View File

@ -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) {}

View File

@ -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,

57
server/jwt.go Normal file
View File

@ -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
}

View File

@ -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) {

View File

@ -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
}

View File

@ -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())
}
}

View File

@ -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)
}) {

View File

@ -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 @@
</header>
<main>
<div>
<button onclick="doThisThing();">Login</button>
<button onclick="doThisThing(true);">Login</button>
</div>
<div style="display:flex; gap: 2em;">
<div>

View File

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