diff --git a/server/auth.go b/server/auth.go index 8d40964..31c0a73 100644 --- a/server/auth.go +++ b/server/auth.go @@ -1,6 +1,7 @@ package server import ( + "errors" "github.com/1f349/lavender/database" "github.com/julienschmidt/httprouter" "net/http" @@ -18,6 +19,8 @@ type UserAuth struct { func (u UserAuth) IsGuest() bool { return u.Subject == "" } +var ErrAuthHttpError = errors.New("auth http error") + 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 @@ -48,18 +51,29 @@ 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) { - authUser, err := h.internalAuthenticationHandler(req) + authUser, err := h.internalAuthenticationHandler(rw, req) if err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) + if !errors.Is(err, ErrAuthHttpError) { + http.Error(rw, err.Error(), http.StatusInternalServerError) + } return } next(rw, req, params, authUser) } } -func (h *HttpServer) internalAuthenticationHandler(req *http.Request) (UserAuth, error) { +func (h *HttpServer) internalAuthenticationHandler(rw http.ResponseWriter, req *http.Request) (UserAuth, error) { + // Delete previous login data cookie + http.SetCookie(rw, &http.Cookie{ + Name: "lavender-login-data", + Path: "/", + MaxAge: -1, + Secure: true, + SameSite: http.SameSiteLaxMode, + }) + var u UserAuth - err := h.readLoginDataCookie(req, &u) + err := h.readLoginAccessCookie(rw, req, &u) if err != nil { // not logged in return UserAuth{}, nil diff --git a/server/db.go b/server/db.go index 3ede57e..f41e106 100644 --- a/server/db.go +++ b/server/db.go @@ -1,22 +1,33 @@ package server import ( + "errors" "github.com/1f349/lavender/database" "github.com/1f349/lavender/logger" "net/http" ) +var ErrDatabaseActionFailed = errors.New("database action failed") + // DbTx wraps a database transaction with http error messages and a simple action // function. If the action function returns an error the transaction will be // rolled back. If there is no error then the transaction is committed. -func (h *HttpServer) DbTx(rw http.ResponseWriter, action func(db *database.Queries) error) bool { - err := action(h.db) - if err != nil { +func (h *HttpServer) DbTx(rw http.ResponseWriter, action func(tx *database.Queries) error) bool { + logger.Logger.Helper() + if h.DbTxError(action) != nil { http.Error(rw, "Database error", http.StatusInternalServerError) - logger.Logger.Helper() - logger.Logger.Warn("Database action error", "err", err) return true } return false } + +func (h *HttpServer) DbTxError(action func(tx *database.Queries) error) error { + logger.Logger.Helper() + err := action(h.db) + if err != nil { + logger.Logger.Warn("Database action error", "err", err) + return ErrDatabaseActionFailed + } + return nil +} diff --git a/server/login.go b/server/login.go index 6a96d75..f5a8abd 100644 --- a/server/login.go +++ b/server/login.go @@ -87,7 +87,7 @@ func (h *HttpServer) loginPost(rw http.ResponseWriter, req *http.Request, _ http // save state for use later state := login.Config.Namespace + ":" + uuid.NewString() - h.flowState.Set(state, flowStateData{login, req.PostFormValue("redirect")}, time.Now().Add(15*time.Minute)) + h.flowState.Set(state, flowStateData{loginName, login, req.PostFormValue("redirect")}, time.Now().Add(15*time.Minute)) // generate oauth2 config and redirect to authorize URL oa2conf := login.OAuth2Config @@ -96,7 +96,7 @@ func (h *HttpServer) loginPost(rw http.ResponseWriter, req *http.Request, _ http http.Redirect(rw, req, nextUrl, http.StatusFound) } -func (h *HttpServer) loginCallback(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { +func (h *HttpServer) loginCallback(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, userAuth UserAuth) { flowState, ok := h.flowState.Get(req.FormValue("state")) if !ok { http.Error(rw, "Invalid flow state", http.StatusBadRequest) @@ -108,13 +108,29 @@ func (h *HttpServer) loginCallback(rw http.ResponseWriter, req *http.Request, _ return } - sessionData, err := h.fetchUserInfo(flowState.sso, token) - if err != nil || sessionData.Subject == "" { - http.Error(rw, "Failed to fetch user info", http.StatusInternalServerError) + userAuth, err = h.updateExternalUserInfo(req, flowState.sso, token) + if err != nil { + http.Error(rw, "Failed to update external user info", http.StatusInternalServerError) return } - if h.DbTx(rw, func(tx *database.Queries) error { + if h.setLoginDataCookie(rw, userAuth, flowState.loginName) { + http.Error(rw, "Failed to save login cookie", http.StatusInternalServerError) + return + } + if flowState.redirect != "" { + req.Form.Set("redirect", flowState.redirect) + } + h.SafeRedirect(rw, req) +} + +func (h *HttpServer) updateExternalUserInfo(req *http.Request, sso *issuer.WellKnownOIDC, token *oauth2.Token) (UserAuth, error) { + sessionData, err := h.fetchUserInfo(sso, token) + if err != nil || sessionData.Subject == "" { + return UserAuth{}, fmt.Errorf("failed to fetch user info") + } + + err = h.DbTxError(func(tx *database.Queries) error { jBytes, err := json.Marshal(sessionData.UserInfo) if err != nil { return err @@ -141,48 +157,51 @@ func (h *HttpServer) loginCallback(rw http.ResponseWriter, req *http.Request, _ Userinfo: string(jBytes), Subject: uEmail, }) - }) { - return + }) + if err != nil { + return UserAuth{}, err } // only continues if the above tx succeeds - auth = sessionData - - if h.DbTx(rw, func(tx *database.Queries) error { + if err := h.DbTxError(func(tx *database.Queries) error { return tx.UpdateUserToken(req.Context(), database.UpdateUserTokenParams{ AccessToken: sql.NullString{String: token.AccessToken, Valid: true}, RefreshToken: sql.NullString{String: token.RefreshToken, Valid: true}, Expiry: sql.NullTime{Time: token.Expiry, Valid: true}, - Subject: auth.Subject, + Subject: sessionData.Subject, }) - }) { - return + }); err != nil { + return UserAuth{}, err } - if h.setLoginDataCookie(rw, auth) { - http.Error(rw, "Failed to save login cookie", http.StatusInternalServerError) - return - } - if flowState.redirect != "" { - req.Form.Set("redirect", flowState.redirect) - } - h.SafeRedirect(rw, req) + return sessionData, nil } -const oneYear = 365 * 24 * time.Hour +const twelveHours = 12 * time.Hour +const oneWeek = 7 * 24 * time.Hour -type lavenderLoginData struct { +type lavenderLoginAccess struct { UserInfo UserInfoFields `json:"user_info"` auth.AccessTokenClaims } -func (l lavenderLoginData) Valid() error { return nil } +func (l lavenderLoginAccess) Valid() error { return l.AccessTokenClaims.Valid() } -func (l lavenderLoginData) Type() string { return "lavender-login-data" } +func (l lavenderLoginAccess) Type() string { return "lavender-login-access" } -func (h *HttpServer) setLoginDataCookie(rw http.ResponseWriter, authData UserAuth) bool { +type lavenderLoginRefresh struct { + Login string `json:"login"` + auth.RefreshTokenClaims +} + +func (l lavenderLoginRefresh) Valid() error { return l.RefreshTokenClaims.Valid() } + +func (l lavenderLoginRefresh) Type() string { return "lavender-login-refresh" } + +func (h *HttpServer) setLoginDataCookie(rw http.ResponseWriter, authData UserAuth, loginName string) bool { ps := claims.NewPermStorage() - gen, err := h.signingKey.GenerateJwt(authData.Subject, uuid.NewString(), jwt.ClaimStrings{h.conf.BaseUrl}, oneYear, lavenderLoginData{ + accId := uuid.NewString() + gen, err := h.signingKey.GenerateJwt(authData.Subject, accId, jwt.ClaimStrings{h.conf.BaseUrl}, twelveHours, lavenderLoginAccess{ UserInfo: authData.UserInfo, AccessTokenClaims: auth.AccessTokenClaims{Perms: ps}, }) @@ -190,29 +209,92 @@ func (h *HttpServer) setLoginDataCookie(rw http.ResponseWriter, authData UserAut http.Error(rw, "Failed to generate cookie token", http.StatusInternalServerError) return true } + ref, err := h.signingKey.GenerateJwt(authData.Subject, uuid.NewString(), jwt.ClaimStrings{h.conf.BaseUrl}, oneWeek, lavenderLoginRefresh{ + Login: loginName, + RefreshTokenClaims: auth.RefreshTokenClaims{AccessTokenId: accId}, + }) + if err != nil { + http.Error(rw, "Failed to generate cookie token", http.StatusInternalServerError) + return true + } http.SetCookie(rw, &http.Cookie{ - Name: "lavender-login-data", + Name: "lavender-login-access", Value: gen, Path: "/", - Expires: time.Now().AddDate(0, 3, 0), Secure: true, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) + http.SetCookie(rw, &http.Cookie{ + Name: "lavender-login-refresh", + Value: ref, + Path: "/", + Expires: time.Now().AddDate(0, 0, 10), + Secure: true, + HttpOnly: true, SameSite: http.SameSiteLaxMode, }) return false } -func (h *HttpServer) readLoginDataCookie(req *http.Request, u *UserAuth) error { - loginCookie, err := req.Cookie("lavender-login-data") +func readJwtCookie[T mjwt.Claims](req *http.Request, cookieName string, signingKey mjwt.Verifier) (mjwt.BaseTypeClaims[T], error) { + loginCookie, err := req.Cookie(cookieName) if err != nil { - return err + return mjwt.BaseTypeClaims[T]{}, err } - _, b, err := mjwt.ExtractClaims[lavenderLoginData](h.signingKey, loginCookie.Value) + _, b, err := mjwt.ExtractClaims[T](signingKey, loginCookie.Value) if err != nil { - return err + return mjwt.BaseTypeClaims[T]{}, err + } + return b, nil +} + +func (h *HttpServer) readLoginAccessCookie(rw http.ResponseWriter, req *http.Request, u *UserAuth) error { + loginData, err := readJwtCookie[lavenderLoginAccess](req, "lavender-login-access", h.signingKey) + if err != nil { + return h.readLoginRefreshCookie(rw, req, u) } *u = UserAuth{ - Subject: b.Subject, - UserInfo: b.Claims.UserInfo, + Subject: loginData.Subject, + UserInfo: loginData.Claims.UserInfo, + } + return nil +} + +func (h *HttpServer) readLoginRefreshCookie(rw http.ResponseWriter, req *http.Request, userAuth *UserAuth) error { + refreshData, err := readJwtCookie[lavenderLoginRefresh](req, "lavender-login-refresh", h.signingKey) + if err != nil { + return err + } + + sso := h.manager.FindServiceFromLogin(refreshData.Claims.Login) + + var oauthToken *oauth2.Token + + err = h.DbTxError(func(tx *database.Queries) error { + token, err := tx.GetUserToken(req.Context(), refreshData.Subject) + if err != nil { + return err + } + if !token.AccessToken.Valid || !token.RefreshToken.Valid || !token.Expiry.Valid { + return fmt.Errorf("invalid oauth token") + } + oauthToken = &oauth2.Token{ + AccessToken: token.AccessToken.String, + RefreshToken: token.RefreshToken.String, + Expiry: token.Expiry.Time, + } + return nil + }) + + *userAuth, err = h.updateExternalUserInfo(req, sso, oauthToken) + if err != nil { + return err + } + + if h.setLoginDataCookie(rw, *userAuth, refreshData.Claims.Login) { + http.Error(rw, "Failed to save login cookie", http.StatusInternalServerError) + return fmt.Errorf("failed to save login cookie: %w", ErrAuthHttpError) } return nil } diff --git a/server/oauth.go b/server/oauth.go index 4cc5a36..1f13e70 100644 --- a/server/oauth.go +++ b/server/oauth.go @@ -125,7 +125,7 @@ func (h *HttpServer) oauthUserAuthorization(rw http.ResponseWriter, req *http.Re return "", err } - auth, err := h.internalAuthenticationHandler(req) + auth, err := h.internalAuthenticationHandler(nil, req) if err != nil { return "", err } diff --git a/server/server.go b/server/server.go index d52b3be..66a937a 100644 --- a/server/server.go +++ b/server/server.go @@ -38,8 +38,9 @@ type HttpServer struct { } type flowStateData struct { - sso *issuer.WellKnownOIDC - redirect string + loginName string + sso *issuer.WellKnownOIDC + redirect string } func NewHttpServer(conf Conf, db *database.Queries, signingKey mjwt.Signer) *http.Server { @@ -126,7 +127,14 @@ func NewHttpServer(conf Conf, db *database.Queries, signingKey mjwt.Signer) *htt } if subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(req.PostFormValue("nonce"))) == 1 { http.SetCookie(rw, &http.Cookie{ - Name: "lavender-login-data", + Name: "lavender-login-access", + Path: "/", + MaxAge: -1, + Secure: true, + SameSite: http.SameSiteLaxMode, + }) + http.SetCookie(rw, &http.Cookie{ + Name: "lavender-login-refresh", Path: "/", MaxAge: -1, Secure: true,