diff --git a/.gitignore b/.gitignore index 3ec9906..d2eb462 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ *.local .idea/ .data/ +node_modules/ +pages/style-minify.css diff --git a/go.mod b/go.mod index 46cae9f..e4867a3 100644 --- a/go.mod +++ b/go.mod @@ -27,7 +27,7 @@ require ( github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/golang-jwt/jwt v3.2.2+incompatible // indirect github.com/google/go-querystring v1.1.0 // indirect - github.com/klauspost/compress v1.15.11 // indirect + github.com/klauspost/compress v1.17.8 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect diff --git a/go.sum b/go.sum index b89409b..0b38578 100644 --- a/go.sum +++ b/go.sum @@ -75,8 +75,8 @@ github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4d github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k= github.com/klauspost/compress v1.15.0/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= -github.com/klauspost/compress v1.15.11 h1:Lcadnb3RKGin4FYM/orgq0qde+nc15E5Cbqg4B9Sx9c= -github.com/klauspost/compress v1.15.11/go.mod h1:QPwzmACJjUTFsnSHH934V6woptycfrDDJnH7hvFVbGM= +github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU= +github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= diff --git a/theme/style.css b/pages/assets/style.css similarity index 100% rename from theme/style.css rename to pages/assets/style.css diff --git a/pages/header.go.html b/pages/header.go.html new file mode 100644 index 0000000..55901e6 --- /dev/null +++ b/pages/header.go.html @@ -0,0 +1,3 @@ +
+

{{.ServiceName}}

+
diff --git a/pages/index-guest.go.html b/pages/index-guest.go.html index e4c9927..3d2db8f 100644 --- a/pages/index-guest.go.html +++ b/pages/index-guest.go.html @@ -2,12 +2,11 @@ {{.ServiceName}} - + + -
-

{{.ServiceName}}

-
+{{template "header.go.html" .}}
Not logged in
diff --git a/pages/index.go.html b/pages/index.go.html index f831713..22bf121 100644 --- a/pages/index.go.html +++ b/pages/index.go.html @@ -2,14 +2,13 @@ {{.ServiceName}} - + + -
-

{{.ServiceName}}

-
+{{template "header.go.html" .}}
-
Logged in as: {{.DisplayName}} ({{.Subject}})
+
Logged in as: {{.Auth.UserInfo.name}} ({{.Auth.Subject}})
diff --git a/pages/login-memory.go.html b/pages/login-memory.go.html index 395eb60..dcb0bc3 100644 --- a/pages/login-memory.go.html +++ b/pages/login-memory.go.html @@ -2,12 +2,11 @@ {{.ServiceName}} - + + -
-

{{.ServiceName}}

-
+{{template "header.go.html" .}}
Log in as: {{.LoginName}}
diff --git a/pages/login.go.html b/pages/login.go.html index 134b2cc..fe45b01 100644 --- a/pages/login.go.html +++ b/pages/login.go.html @@ -2,12 +2,11 @@ {{.ServiceName}} - + + -
-

{{.ServiceName}}

-
+{{template "header.go.html" .}}
diff --git a/pages/manage-apps.go.html b/pages/manage-apps.go.html index c18d992..f290372 100644 --- a/pages/manage-apps.go.html +++ b/pages/manage-apps.go.html @@ -2,7 +2,8 @@ {{.ServiceName}} - + + -
-

{{.ServiceName}}

-
+{{template "header.go.html" .}}
diff --git a/pages/manage-users.go.html b/pages/manage-users.go.html index fdda110..48dc3cb 100644 --- a/pages/manage-users.go.html +++ b/pages/manage-users.go.html @@ -2,12 +2,11 @@ {{.ServiceName}} - + + -
-

{{.ServiceName}}

-
+{{template "header.go.html" .}}
diff --git a/pages/oauth-authorize.go.html b/pages/oauth-authorize.go.html index ae31166..7655194 100644 --- a/pages/oauth-authorize.go.html +++ b/pages/oauth-authorize.go.html @@ -2,12 +2,11 @@ {{.ServiceName}} - + + -
-

{{.ServiceName}}

-
+{{template "header.go.html" .}}
The application {{.AppName}} wants to access your account ({{.DisplayName}}). It requests the following permissions:
diff --git a/pages/pages.go b/pages/pages.go index 8ec7723..7a296ee 100644 --- a/pages/pages.go +++ b/pages/pages.go @@ -1,34 +1,36 @@ package pages import ( + "bytes" "embed" _ "embed" "errors" "github.com/1f349/lavender/logger" + "github.com/1f349/lavender/utils" "github.com/1f349/overlapfs" "html/template" "io" "io/fs" "os" "path/filepath" - "sync" ) var ( - //go:embed *.go.html + //go:embed *.go.html assets/*.css wwwPages embed.FS wwwTemplates *template.Template - loadOnce sync.Once + loadOnce utils.Once[error] + cssAssetMap = make(map[string][]byte) ) -func LoadPages(wd string) (err error) { - loadOnce.Do(func() { +func LoadPages(wd string) error { + return loadOnce.Do(func() (err error) { var o fs.FS = wwwPages if wd != "" { wwwDir := filepath.Join(wd, "www") err = os.Mkdir(wwwDir, os.ModePerm) if err != nil && !errors.Is(err, os.ErrExist) { - return + return err } wdFs := os.DirFS(wwwDir) o = overlapfs.OverlapFS{A: wwwPages, B: wdFs} @@ -36,8 +38,19 @@ func LoadPages(wd string) (err error) { wwwTemplates, err = template.New("pages").Funcs(template.FuncMap{ "emailHide": EmailHide, }).ParseFS(o, "*.go.html") + + glob, err := fs.Glob(o, "assets/*") + if err != nil { + return err + } + for _, i := range glob { + cssAssetMap[i], err = fs.ReadFile(o, i) + if err != nil { + return err + } + } + return nil }) - return err } func RenderPageTemplate(wr io.Writer, name string, data any) { @@ -47,6 +60,14 @@ func RenderPageTemplate(wr io.Writer, name string, data any) { } } +func RenderCss(name string) io.ReadSeeker { + b, ok := cssAssetMap[name] + if !ok { + return nil + } + return bytes.NewReader(b) +} + func EmailHide(a string) string { b := []byte(a) for i := range b { diff --git a/server/auth.go b/server/auth.go index 972eda6..1178d1c 100644 --- a/server/auth.go +++ b/server/auth.go @@ -2,8 +2,6 @@ package server import ( "github.com/1f349/lavender/database" - "github.com/1f349/mjwt" - "github.com/1f349/mjwt/auth" "github.com/julienschmidt/httprouter" "net/http" "net/url" @@ -13,18 +11,18 @@ import ( type UserHandler func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) type UserAuth struct { - ID string + Subject string DisplayName string UserInfo UserInfoFields } -func (u UserAuth) IsGuest() bool { return u.ID == "" } +func (u UserAuth) IsGuest() bool { return u.Subject == "" } func (h *HttpServer) RequireAdminAuthentication(next UserHandler) httprouter.Handle { return h.RequireAuthentication(func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) { var roles string if h.DbTx(rw, func(tx *database.Tx) (err error) { - roles, err = tx.GetUserRoles(auth.ID) + roles, err = tx.GetUserRoles(auth.Subject) return }) { return @@ -50,29 +48,23 @@ func (h *HttpServer) RequireAuthentication(next UserHandler) httprouter.Handle { func (h *HttpServer) OptionalAuthentication(next UserHandler) httprouter.Handle { return func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) { - auth, err := h.internalAuthenticationHandler(req) + authUser, err := h.internalAuthenticationHandler(req) if err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return } - if auth.IsGuest() { - // if this fails internally it just sees the user as logged out - h.readLoginDataCookie(req, &auth) - } - next(rw, req, params, auth) + next(rw, req, params, authUser) } } func (h *HttpServer) internalAuthenticationHandler(req *http.Request) (UserAuth, error) { - if loginCookie, err := req.Cookie("lavender-login-data"); err == nil { - _, b, err := mjwt.ExtractClaims[auth.AccessTokenClaims](h.signingKey, loginCookie.Value) - if err != nil { - return UserAuth{}, err - } - return UserAuth{ID: b.Subject}, nil + var u UserAuth + err := h.readLoginDataCookie(req, &u) + if err != nil { + // not logged in + return UserAuth{}, nil } - // not logged in - return UserAuth{}, nil + return u, nil } func PrepareRedirectUrl(targetPath string, origin *url.URL) *url.URL { diff --git a/server/home.go b/server/home.go index 7e06845..4e28c61 100644 --- a/server/home.go +++ b/server/home.go @@ -30,7 +30,7 @@ func (h *HttpServer) Home(rw http.ResponseWriter, _ *http.Request, _ httprouter. var isAdmin bool h.DbTx(rw, func(tx *database.Tx) (err error) { - roles, err := tx.GetUserRoles(auth.ID) + roles, err := tx.GetUserRoles(auth.Subject) isAdmin = HasRole(roles, "lavender:admin") return err }) @@ -38,8 +38,6 @@ func (h *HttpServer) Home(rw http.ResponseWriter, _ *http.Request, _ httprouter. pages.RenderPageTemplate(rw, "index", map[string]any{ "ServiceName": h.conf.ServiceName, "Auth": auth, - "Subject": auth.ID, - "DisplayName": auth.DisplayName, "Nonce": lNonce, "IsAdmin": isAdmin, }) diff --git a/server/login.go b/server/login.go index 63496bb..97a07de 100644 --- a/server/login.go +++ b/server/login.go @@ -2,17 +2,14 @@ package server import ( "context" - "crypto/rand" - "crypto/rsa" - "crypto/sha256" "database/sql" - "encoding/hex" "encoding/json" "errors" "fmt" "github.com/1f349/lavender/database" "github.com/1f349/lavender/issuer" "github.com/1f349/lavender/pages" + "github.com/1f349/mjwt" "github.com/1f349/mjwt/auth" "github.com/1f349/mjwt/claims" "github.com/golang-jwt/jwt/v4" @@ -112,7 +109,7 @@ func (h *HttpServer) loginCallback(rw http.ResponseWriter, req *http.Request, _ } sessionData, err := h.fetchUserInfo(flowState.sso, token) - if sessionData.ID == "" { + if err != nil || sessionData.Subject == "" { http.Error(rw, "Failed to fetch user info", http.StatusInternalServerError) return } @@ -122,15 +119,15 @@ func (h *HttpServer) loginCallback(rw http.ResponseWriter, req *http.Request, _ if err != nil { return err } - _, err = tx.GetUser(sessionData.ID) + _, err = tx.GetUser(sessionData.Subject) if errors.Is(err, sql.ErrNoRows) { uEmail := sessionData.UserInfo.GetStringOrDefault("email", "unknown@localhost") uEmailVerified, _ := sessionData.UserInfo.GetBoolean("email_verified") - return tx.InsertUser(sessionData.ID, uEmail, uEmailVerified, "", string(jBytes), true) + return tx.InsertUser(sessionData.Subject, uEmail, uEmailVerified, "", string(jBytes), true) } uEmail := sessionData.UserInfo.GetStringOrDefault("email", "unknown@localhost") uEmailVerified, _ := sessionData.UserInfo.GetBoolean("email_verified") - return tx.UpdateUserInfo(sessionData.ID, uEmail, uEmailVerified, string(jBytes)) + return tx.UpdateUserInfo(sessionData.Subject, uEmail, uEmailVerified, string(jBytes)) }) { return } @@ -139,7 +136,7 @@ func (h *HttpServer) loginCallback(rw http.ResponseWriter, req *http.Request, _ auth = sessionData if h.DbTx(rw, func(tx *database.Tx) error { - return tx.UpdateUserToken(auth.ID, token.AccessToken, token.RefreshToken, token.Expiry) + return tx.UpdateUserToken(auth.Subject, token.AccessToken, token.RefreshToken, token.Expiry) }) { return } @@ -156,9 +153,21 @@ func (h *HttpServer) loginCallback(rw http.ResponseWriter, req *http.Request, _ const oneYear = 365 * 24 * time.Hour +type lavenderLoginData struct { + UserInfo UserInfoFields `json:"user_info"` + auth.AccessTokenClaims +} + +func (l lavenderLoginData) Valid() error { return nil } + +func (l lavenderLoginData) Type() string { return "lavender-login-data" } + func (h *HttpServer) setLoginDataCookie(rw http.ResponseWriter, authData UserAuth) bool { ps := claims.NewPermStorage() - gen, err := h.signingKey.GenerateJwt(authData.ID, uuid.NewString(), jwt.ClaimStrings{h.conf.BaseUrl}, oneYear, auth.AccessTokenClaims{Perms: ps}) + gen, err := h.signingKey.GenerateJwt(authData.Subject, uuid.NewString(), jwt.ClaimStrings{h.conf.BaseUrl}, oneYear, lavenderLoginData{ + UserInfo: authData.UserInfo, + AccessTokenClaims: auth.AccessTokenClaims{Perms: ps}, + }) if err != nil { http.Error(rw, "Failed to generate cookie token", http.StatusInternalServerError) return true @@ -174,34 +183,20 @@ func (h *HttpServer) setLoginDataCookie(rw http.ResponseWriter, authData UserAut return false } -func (h *HttpServer) readLoginDataCookie(req *http.Request, u *UserAuth) { +func (h *HttpServer) readLoginDataCookie(req *http.Request, u *UserAuth) error { loginCookie, err := req.Cookie("lavender-login-data") if err != nil { - return + return err } - hexData, err := hex.DecodeString(loginCookie.Value) + _, b, err := mjwt.ExtractClaims[lavenderLoginData](h.signingKey, loginCookie.Value) if err != nil { - return + return err } - decData, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, h.signingKey.PrivateKey(), hexData, []byte("lavender-login-data")) - if err != nil { - return + *u = UserAuth{ + Subject: b.Subject, + UserInfo: b.Claims.UserInfo, } - - 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 - } - - sso := h.manager.FindServiceFromLogin(userId) - if sso == nil { - return - } - - *u, _ = h.fetchUserInfo(sso, &token) + return nil } func (h *HttpServer) fetchUserInfo(sso *issuer.WellKnownOIDC, token *oauth2.Token) (UserAuth, error) { @@ -221,10 +216,8 @@ func (h *HttpServer) fetchUserInfo(sso *issuer.WellKnownOIDC, token *oauth2.Toke } subject += "@" + sso.Config.Namespace - displayName := userInfoJson.GetStringOrDefault("name", "Unknown Name") return UserAuth{ - ID: subject, - DisplayName: displayName, - UserInfo: userInfoJson, + Subject: subject, + UserInfo: userInfoJson, }, nil } diff --git a/server/manage-apps.go b/server/manage-apps.go index 0232488..7cb7fa7 100644 --- a/server/manage-apps.go +++ b/server/manage-apps.go @@ -26,11 +26,11 @@ func (h *HttpServer) ManageAppsGet(rw http.ResponseWriter, req *http.Request, _ var roles string var appList []database.ClientInfoDbOutput if h.DbTx(rw, func(tx *database.Tx) (err error) { - roles, err = tx.GetUserRoles(auth.ID) + roles, err = tx.GetUserRoles(auth.Subject) if err != nil { return } - appList, err = tx.GetAppList(auth.ID, HasRole(roles, "lavender:admin"), offset) + appList, err = tx.GetAppList(auth.Subject, HasRole(roles, "lavender:admin"), offset) return }) { return @@ -80,7 +80,7 @@ func (h *HttpServer) ManageAppsPost(rw http.ResponseWriter, req *http.Request, _ if sso || hasPerms { var roles string if h.DbTx(rw, func(tx *database.Tx) (err error) { - roles, err = tx.GetUserRoles(auth.ID) + roles, err = tx.GetUserRoles(auth.Subject) return }) { return @@ -98,7 +98,7 @@ func (h *HttpServer) ManageAppsPost(rw http.ResponseWriter, req *http.Request, _ switch action { case "create": if h.DbTx(rw, func(tx *database.Tx) error { - return tx.InsertClientApp(name, domain, auth.ID, perms, public, sso, active) + return tx.InsertClientApp(name, domain, auth.Subject, perms, public, sso, active) }) { return } @@ -108,7 +108,7 @@ func (h *HttpServer) ManageAppsPost(rw http.ResponseWriter, req *http.Request, _ if err != nil { return err } - return tx.UpdateClientApp(sub, auth.ID, name, domain, perms, hasPerms, public, sso, active) + return tx.UpdateClientApp(sub, auth.Subject, name, domain, perms, hasPerms, public, sso, active) }) { return } @@ -124,7 +124,7 @@ func (h *HttpServer) ManageAppsPost(rw http.ResponseWriter, req *http.Request, _ if err != nil { return err } - secret, err = tx.ResetClientAppSecret(sub, auth.ID) + secret, err = tx.ResetClientAppSecret(sub, auth.Subject) return err }) { return diff --git a/server/manage-users.go b/server/manage-users.go index 9882479..d777ce4 100644 --- a/server/manage-users.go +++ b/server/manage-users.go @@ -24,7 +24,7 @@ func (h *HttpServer) ManageUsersGet(rw http.ResponseWriter, req *http.Request, _ var roles string var userList []database.User if h.DbTx(rw, func(tx *database.Tx) (err error) { - roles, err = tx.GetUserRoles(auth.ID) + roles, err = tx.GetUserRoles(auth.Subject) if err != nil { return } @@ -43,7 +43,7 @@ func (h *HttpServer) ManageUsersGet(rw http.ResponseWriter, req *http.Request, _ "Users": userList, "Offset": offset, "EmailShow": req.URL.Query().Has("show-email"), - "CurrentAdmin": auth.ID, + "CurrentAdmin": auth.Subject, } if q.Has("edit") { for _, i := range userList { @@ -71,7 +71,7 @@ func (h *HttpServer) ManageUsersPost(rw http.ResponseWriter, req *http.Request, var roles string if h.DbTx(rw, func(tx *database.Tx) (err error) { - roles, err = tx.GetUserRoles(auth.ID) + roles, err = tx.GetUserRoles(auth.Subject) return }) { return diff --git a/server/oauth.go b/server/oauth.go index 4c5ff9e..4cc5a36 100644 --- a/server/oauth.go +++ b/server/oauth.go @@ -147,5 +147,5 @@ func (h *HttpServer) oauthUserAuthorization(rw http.ResponseWriter, req *http.Re http.Redirect(rw, req, redirectUrl.String(), http.StatusFound) return "", nil } - return auth.ID, nil + return auth.Subject, nil } diff --git a/server/server.go b/server/server.go index ea190dc..c86f758 100644 --- a/server/server.go +++ b/server/server.go @@ -1,7 +1,6 @@ package server import ( - "bytes" "crypto/subtle" "encoding/json" "github.com/1f349/cache" @@ -10,8 +9,8 @@ import ( "github.com/1f349/lavender/issuer" "github.com/1f349/lavender/logger" "github.com/1f349/lavender/openid" + "github.com/1f349/lavender/pages" scope2 "github.com/1f349/lavender/scope" - "github.com/1f349/lavender/theme" "github.com/1f349/mjwt" "github.com/go-oauth2/oauth2/v4/errors" "github.com/go-oauth2/oauth2/v4/manage" @@ -20,6 +19,7 @@ import ( "github.com/julienschmidt/httprouter" "net/http" "net/url" + "path" "strings" "time" ) @@ -44,6 +44,7 @@ type flowStateData struct { func NewHttpServer(conf Conf, db *database.DB, signingKey mjwt.Signer) *http.Server { r := httprouter.New() + contentCache := time.Now() // remove last slash from baseUrl { @@ -139,8 +140,14 @@ func NewHttpServer(conf Conf, db *database.DB, signingKey mjwt.Signer) *http.Ser })) // theme styles - r.GET("/theme/style.css", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) { - http.ServeContent(rw, req, "style.css", time.Now(), bytes.NewReader(theme.DefaultThemeCss)) + r.GET("/assets/*filepath", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) { + name := params.ByName("filepath") + if strings.Contains(name, "..") { + http.Error(rw, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + out := pages.RenderCss(path.Join("assets", name)) + http.ServeContent(rw, req, path.Base(name), contentCache, out) }) // management pages diff --git a/theme/theme.go b/theme/theme.go deleted file mode 100644 index dd91b87..0000000 --- a/theme/theme.go +++ /dev/null @@ -1,6 +0,0 @@ -package theme - -import _ "embed" - -//go:embed style.css -var DefaultThemeCss []byte diff --git a/utils/once.go b/utils/once.go new file mode 100644 index 0000000..b649851 --- /dev/null +++ b/utils/once.go @@ -0,0 +1,15 @@ +package utils + +import "sync" + +type Once[T any] struct { + once sync.Once + value T +} + +func (o *Once[T]) Do(f func() T) T { + o.once.Do(func() { + o.value = f() + }) + return o.value +}