Redesign token authentication

This commit is contained in:
Melon 2024-05-31 13:51:44 +01:00
parent 46450ca2f1
commit 68f274b45e
Signed by: melon
GPG Key ID: 6C9D970C50D26A25
5 changed files with 165 additions and 50 deletions

View File

@ -1,6 +1,7 @@
package server package server
import ( import (
"errors"
"github.com/1f349/lavender/database" "github.com/1f349/lavender/database"
"github.com/julienschmidt/httprouter" "github.com/julienschmidt/httprouter"
"net/http" "net/http"
@ -18,6 +19,8 @@ type UserAuth struct {
func (u UserAuth) IsGuest() bool { return u.Subject == "" } func (u UserAuth) IsGuest() bool { return u.Subject == "" }
var ErrAuthHttpError = errors.New("auth http error")
func (h *HttpServer) RequireAdminAuthentication(next UserHandler) httprouter.Handle { func (h *HttpServer) RequireAdminAuthentication(next UserHandler) httprouter.Handle {
return h.RequireAuthentication(func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) { return h.RequireAuthentication(func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) {
var roles string var roles string
@ -48,18 +51,29 @@ func (h *HttpServer) RequireAuthentication(next UserHandler) httprouter.Handle {
func (h *HttpServer) OptionalAuthentication(next UserHandler) httprouter.Handle { func (h *HttpServer) OptionalAuthentication(next UserHandler) httprouter.Handle {
return func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) { 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 { if err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError) if !errors.Is(err, ErrAuthHttpError) {
http.Error(rw, err.Error(), http.StatusInternalServerError)
}
return return
} }
next(rw, req, params, authUser) 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 var u UserAuth
err := h.readLoginDataCookie(req, &u) err := h.readLoginAccessCookie(rw, req, &u)
if err != nil { if err != nil {
// not logged in // not logged in
return UserAuth{}, nil return UserAuth{}, nil

View File

@ -1,22 +1,33 @@
package server package server
import ( import (
"errors"
"github.com/1f349/lavender/database" "github.com/1f349/lavender/database"
"github.com/1f349/lavender/logger" "github.com/1f349/lavender/logger"
"net/http" "net/http"
) )
var ErrDatabaseActionFailed = errors.New("database action failed")
// DbTx wraps a database transaction with http error messages and a simple action // 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 // function. If the action function returns an error the transaction will be
// rolled back. If there is no error then the transaction is committed. // 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 { func (h *HttpServer) DbTx(rw http.ResponseWriter, action func(tx *database.Queries) error) bool {
err := action(h.db) logger.Logger.Helper()
if err != nil { if h.DbTxError(action) != nil {
http.Error(rw, "Database error", http.StatusInternalServerError) http.Error(rw, "Database error", http.StatusInternalServerError)
logger.Logger.Helper()
logger.Logger.Warn("Database action error", "err", err)
return true return true
} }
return false 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
}

View File

@ -87,7 +87,7 @@ func (h *HttpServer) loginPost(rw http.ResponseWriter, req *http.Request, _ http
// save state for use later // save state for use later
state := login.Config.Namespace + ":" + uuid.NewString() 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 // generate oauth2 config and redirect to authorize URL
oa2conf := login.OAuth2Config 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) 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")) flowState, ok := h.flowState.Get(req.FormValue("state"))
if !ok { if !ok {
http.Error(rw, "Invalid flow state", http.StatusBadRequest) http.Error(rw, "Invalid flow state", http.StatusBadRequest)
@ -108,13 +108,29 @@ func (h *HttpServer) loginCallback(rw http.ResponseWriter, req *http.Request, _
return return
} }
sessionData, err := h.fetchUserInfo(flowState.sso, token) userAuth, err = h.updateExternalUserInfo(req, flowState.sso, token)
if err != nil || sessionData.Subject == "" { if err != nil {
http.Error(rw, "Failed to fetch user info", http.StatusInternalServerError) http.Error(rw, "Failed to update external user info", http.StatusInternalServerError)
return 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) jBytes, err := json.Marshal(sessionData.UserInfo)
if err != nil { if err != nil {
return err return err
@ -141,48 +157,51 @@ func (h *HttpServer) loginCallback(rw http.ResponseWriter, req *http.Request, _
Userinfo: string(jBytes), Userinfo: string(jBytes),
Subject: uEmail, Subject: uEmail,
}) })
}) { })
return if err != nil {
return UserAuth{}, err
} }
// only continues if the above tx succeeds // only continues if the above tx succeeds
auth = sessionData if err := h.DbTxError(func(tx *database.Queries) error {
if h.DbTx(rw, func(tx *database.Queries) error {
return tx.UpdateUserToken(req.Context(), database.UpdateUserTokenParams{ return tx.UpdateUserToken(req.Context(), database.UpdateUserTokenParams{
AccessToken: sql.NullString{String: token.AccessToken, Valid: true}, AccessToken: sql.NullString{String: token.AccessToken, Valid: true},
RefreshToken: sql.NullString{String: token.RefreshToken, Valid: true}, RefreshToken: sql.NullString{String: token.RefreshToken, Valid: true},
Expiry: sql.NullTime{Time: token.Expiry, Valid: true}, Expiry: sql.NullTime{Time: token.Expiry, Valid: true},
Subject: auth.Subject, Subject: sessionData.Subject,
}) })
}) { }); err != nil {
return return UserAuth{}, err
} }
if h.setLoginDataCookie(rw, auth) { return sessionData, nil
http.Error(rw, "Failed to save login cookie", http.StatusInternalServerError)
return
}
if flowState.redirect != "" {
req.Form.Set("redirect", flowState.redirect)
}
h.SafeRedirect(rw, req)
} }
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"` UserInfo UserInfoFields `json:"user_info"`
auth.AccessTokenClaims 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() 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, UserInfo: authData.UserInfo,
AccessTokenClaims: auth.AccessTokenClaims{Perms: ps}, 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) http.Error(rw, "Failed to generate cookie token", http.StatusInternalServerError)
return true 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{ http.SetCookie(rw, &http.Cookie{
Name: "lavender-login-data", Name: "lavender-login-access",
Value: gen, Value: gen,
Path: "/", Path: "/",
Expires: time.Now().AddDate(0, 3, 0),
Secure: true, 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, SameSite: http.SameSiteLaxMode,
}) })
return false return false
} }
func (h *HttpServer) readLoginDataCookie(req *http.Request, u *UserAuth) error { func readJwtCookie[T mjwt.Claims](req *http.Request, cookieName string, signingKey mjwt.Verifier) (mjwt.BaseTypeClaims[T], error) {
loginCookie, err := req.Cookie("lavender-login-data") loginCookie, err := req.Cookie(cookieName)
if err != nil { 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 { 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{ *u = UserAuth{
Subject: b.Subject, Subject: loginData.Subject,
UserInfo: b.Claims.UserInfo, 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 return nil
} }

View File

@ -125,7 +125,7 @@ func (h *HttpServer) oauthUserAuthorization(rw http.ResponseWriter, req *http.Re
return "", err return "", err
} }
auth, err := h.internalAuthenticationHandler(req) auth, err := h.internalAuthenticationHandler(nil, req)
if err != nil { if err != nil {
return "", err return "", err
} }

View File

@ -38,8 +38,9 @@ type HttpServer struct {
} }
type flowStateData struct { type flowStateData struct {
sso *issuer.WellKnownOIDC loginName string
redirect string sso *issuer.WellKnownOIDC
redirect string
} }
func NewHttpServer(conf Conf, db *database.Queries, signingKey mjwt.Signer) *http.Server { 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 { if subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(req.PostFormValue("nonce"))) == 1 {
http.SetCookie(rw, &http.Cookie{ 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: "/", Path: "/",
MaxAge: -1, MaxAge: -1,
Secure: true, Secure: true,