Redesign token authentication

This commit is contained in:
Melon 2024-05-31 14:57:54 +01:00
parent 807c5c540c
commit 3555742316
Signed by: melon
GPG Key ID: 6C9D970C50D26A25
11 changed files with 152 additions and 63 deletions

View File

@ -1,8 +1,7 @@
package server
import (
"github.com/1f349/mjwt"
"github.com/1f349/mjwt/auth"
"errors"
"github.com/1f349/tulip/database"
"github.com/1f349/tulip/database/types"
"github.com/julienschmidt/httprouter"
@ -14,7 +13,7 @@ import (
type UserHandler func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth)
type UserAuth struct {
ID string
Subject string
NeedOtp bool
}
@ -26,14 +25,16 @@ func (u UserAuth) NextFlowUrl(origin *url.URL) *url.URL {
}
func (u UserAuth) IsGuest() bool {
return u.ID == ""
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 role types.UserRole
if h.DbTx(rw, func(tx *database.Queries) (err error) {
role, err = tx.GetUserRole(req.Context(), auth.ID)
role, err = tx.GetUserRole(req.Context(), auth.Subject)
return
}) {
return
@ -59,28 +60,38 @@ func (h *HttpServer) RequireAuthentication(next UserHandler) httprouter.Handle {
func (h *HttpServer) OptionalAuthentication(flowPart bool, next UserHandler) httprouter.Handle {
return func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
authData, err := h.internalAuthenticationHandler(req)
if err == nil {
authData, err := h.internalAuthenticationHandler(rw, req)
if err != nil {
if !errors.Is(err, ErrAuthHttpError) {
http.Error(rw, err.Error(), http.StatusInternalServerError)
}
return
}
if n := authData.NextFlowUrl(req.URL); n != nil && !flowPart {
http.Redirect(rw, req, n.String(), http.StatusFound)
return
}
}
next(rw, req, params, authData)
}
}
func (h *HttpServer) internalAuthenticationHandler(req *http.Request) (UserAuth, error) {
if loginCookie, err := req.Cookie("tulip-login-data"); err == nil {
_, b, err := mjwt.ExtractClaims[auth.AccessTokenClaims](h.signingKey, loginCookie.Value)
func (h *HttpServer) internalAuthenticationHandler(rw http.ResponseWriter, req *http.Request) (UserAuth, error) {
http.SetCookie(rw, &http.Cookie{
Name: "tulip-login-data",
Path: "/",
MaxAge: -1,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
var u UserAuth
err := h.readLoginAccessCookie(rw, req, &u)
if err != nil {
return UserAuth{}, err
}
return UserAuth{ID: b.Subject, NeedOtp: b.Claims.Perms.Has("needs-otp")}, nil
}
// not logged in
return UserAuth{}, nil
}
return u, nil
}
func PrepareRedirectUrl(targetPath string, origin *url.URL) *url.URL {
// find start of query parameters in target path

View File

@ -21,7 +21,7 @@ func TestUserAuth_NextFlowUrl(t *testing.T) {
func TestUserAuth_IsGuest(t *testing.T) {
var u UserAuth
assert.True(t, u.IsGuest())
u.ID = uuid.NewString()
u.Subject = uuid.NewString()
assert.False(t, u.IsGuest())
}
@ -47,10 +47,10 @@ func TestOptionalAuthentication(t *testing.T) {
h := &HttpServer{}
req, err := http.NewRequest(http.MethodGet, "https://example.com/hello", nil)
assert.NoError(t, err)
auth, err := h.internalAuthenticationHandler(req)
auth, err := h.internalAuthenticationHandler(nil, req)
assert.NoError(t, err)
assert.True(t, auth.IsGuest())
auth.ID = "567"
auth.Subject = "567"
}
func TestPrepareRedirectUrl(t *testing.T) {

View File

@ -1,22 +1,33 @@
package server
import (
"errors"
"github.com/1f349/tulip/database"
"github.com/1f349/tulip/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 {
http.Error(rw, "Database error", http.StatusInternalServerError)
func (h *HttpServer) DbTx(rw http.ResponseWriter, action func(tx *database.Queries) error) bool {
logger.Logger.Helper()
logger.Logger.Warn("Database action error", "err", err)
if h.DbTxError(action) != nil {
http.Error(rw, "Database error", http.StatusInternalServerError)
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
}

View File

@ -16,7 +16,7 @@ func (h *HttpServer) EditGet(rw http.ResponseWriter, req *http.Request, _ httpro
if h.DbTx(rw, func(tx *database.Queries) error {
var err error
user, err = tx.GetUser(req.Context(), auth.ID)
user, err = tx.GetUser(req.Context(), auth.Subject)
if err != nil {
return fmt.Errorf("failed to read user data: %w", err)
}
@ -73,7 +73,7 @@ func (h *HttpServer) EditPost(rw http.ResponseWriter, req *http.Request, _ httpr
Zoneinfo: patch.ZoneInfo,
Locale: patch.Locale,
UpdatedAt: time.Now(),
Subject: auth.ID,
Subject: auth.Subject,
}
if h.DbTx(rw, func(tx *database.Queries) error {
if err := tx.ModifyUser(req.Context(), m); err != nil {

View File

@ -34,15 +34,15 @@ func (h *HttpServer) Home(rw http.ResponseWriter, req *http.Request, _ httproute
var userRole types.UserRole
var hasTwoFactor bool
if h.DbTx(rw, func(tx *database.Queries) (err error) {
userWithName, err = tx.GetUserDisplayName(req.Context(), auth.ID)
userWithName, err = tx.GetUserDisplayName(req.Context(), auth.Subject)
if err != nil {
return fmt.Errorf("failed to get user display name: %w", err)
}
hasTwoFactor, err = tx.HasOtp(req.Context(), auth.ID)
hasTwoFactor, err = tx.HasOtp(req.Context(), auth.Subject)
if err != nil {
return fmt.Errorf("failed to get user two factor state: %w", err)
}
userRole, err = tx.GetUserRole(req.Context(), auth.ID)
userRole, err = tx.GetUserRole(req.Context(), auth.Subject)
if err != nil {
return fmt.Errorf("failed to get user role: %w", err)
}
@ -53,7 +53,7 @@ func (h *HttpServer) Home(rw http.ResponseWriter, req *http.Request, _ httproute
pages.RenderPageTemplate(rw, "index", map[string]any{
"ServiceName": h.conf.ServiceName,
"Auth": auth,
"User": database.User{Subject: auth.ID, Name: userWithName, Role: userRole},
"User": database.User{Subject: auth.Subject, Name: userWithName, Role: userRole},
"Nonce": lNonce,
"OtpEnabled": hasTwoFactor,
"IsAdmin": userRole == types.RoleAdmin,

View File

@ -4,6 +4,7 @@ import (
"database/sql"
"errors"
"fmt"
"github.com/1f349/mjwt"
"github.com/1f349/mjwt/auth"
"github.com/1f349/mjwt/claims"
"github.com/1f349/tulip/database"
@ -35,8 +36,8 @@ func getUserLoginName(req *http.Request) string {
return originUrl.Query().Get("login_name")
}
func (h *HttpServer) LoginGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
if !auth.IsGuest() {
func (h *HttpServer) LoginGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, userAuth UserAuth) {
if !userAuth.IsGuest() {
h.SafeRedirect(rw, req)
return
}
@ -53,7 +54,7 @@ func (h *HttpServer) LoginGet(rw http.ResponseWriter, req *http.Request, _ httpr
})
}
func (h *HttpServer) LoginPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
func (h *HttpServer) LoginPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, userAuth UserAuth) {
un := req.FormValue("username")
pw := req.FormValue("password")
@ -121,12 +122,13 @@ func (h *HttpServer) LoginPost(rw http.ResponseWriter, req *http.Request, _ http
}
// only continues if the above tx succeeds
auth = UserAuth{
ID: userInfo.Subject,
userAuth = UserAuth{
Subject: userInfo.Subject,
NeedOtp: hasOtp,
}
if h.setLoginDataCookie(rw, auth) {
if h.setLoginDataCookie(rw, userAuth) {
http.Error(rw, "Failed to save login cookie", http.StatusInternalServerError)
return
}
@ -144,29 +146,87 @@ func (h *HttpServer) LoginPost(rw http.ResponseWriter, req *http.Request, _ http
h.SafeRedirect(rw, req)
}
const oneYear = 365 * 24 * time.Hour
const twelveHours = 12 * time.Hour
const oneMonth = 30 * 24 * time.Hour
func (h *HttpServer) setLoginDataCookie(rw http.ResponseWriter, authData UserAuth) bool {
ps := claims.NewPermStorage()
if authData.NeedOtp {
ps.Set("needs-otp")
}
gen, err := h.signingKey.GenerateJwt(authData.ID, uuid.NewString(), jwt.ClaimStrings{h.conf.BaseUrl}, oneYear, auth.AccessTokenClaims{Perms: ps})
accId := uuid.NewString()
gen, err := h.signingKey.GenerateJwt(authData.Subject, accId, jwt.ClaimStrings{h.conf.BaseUrl}, twelveHours, auth.AccessTokenClaims{Perms: ps})
if err != nil {
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}, oneMonth, 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: "tulip-login-data",
Name: "tulip-login-access",
Value: gen,
Path: "/",
Expires: time.Now().AddDate(1, 0, 0),
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
http.SetCookie(rw, &http.Cookie{
Name: "tulip-login-refresh",
Value: ref,
Path: "/",
Expires: time.Now().AddDate(0, 0, 32),
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
return false
}
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 mjwt.BaseTypeClaims[T]{}, err
}
_, b, err := mjwt.ExtractClaims[T](signingKey, loginCookie.Value)
if err != nil {
return mjwt.BaseTypeClaims[T]{}, err
}
return b, nil
}
func (h *HttpServer) readLoginAccessCookie(rw http.ResponseWriter, req *http.Request, u *UserAuth) error {
loginData, err := readJwtCookie[auth.AccessTokenClaims](req, "tulip-login-access", h.signingKey)
if err != nil {
return h.readLoginRefreshCookie(rw, req, u)
}
*u = UserAuth{
Subject: loginData.Subject,
NeedOtp: loginData.Claims.Perms.Has("needs-otp"),
}
return nil
}
func (h *HttpServer) readLoginRefreshCookie(rw http.ResponseWriter, req *http.Request, userAuth *UserAuth) error {
refreshData, err := readJwtCookie[auth.RefreshTokenClaims](req, "tulip-login-refresh", h.signingKey)
if err != nil {
return err
}
*userAuth = UserAuth{
Subject: refreshData.Subject,
NeedOtp: false,
}
if h.setLoginDataCookie(rw, *userAuth) {
http.Error(rw, "Failed to save login cookie", http.StatusInternalServerError)
return fmt.Errorf("failed to save login cookie: %w", ErrAuthHttpError)
}
return nil
}
func (h *HttpServer) LoginResetPasswordPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) {
email := req.PostFormValue("email")
address, err := mail.ParseAddress(email)

View File

@ -27,12 +27,12 @@ func (h *HttpServer) ManageAppsGet(rw http.ResponseWriter, req *http.Request, _
var role types.UserRole
var appList []database.GetAppListRow
if h.DbTx(rw, func(tx *database.Queries) (err error) {
role, err = tx.GetUserRole(req.Context(), auth.ID)
role, err = tx.GetUserRole(req.Context(), auth.Subject)
if err != nil {
return
}
appList, err = tx.GetAppList(req.Context(), database.GetAppListParams{
Owner: auth.ID,
Owner: auth.Subject,
Column2: role == types.RoleAdmin,
Offset: int64(offset),
})
@ -84,7 +84,7 @@ func (h *HttpServer) ManageAppsPost(rw http.ResponseWriter, req *http.Request, _
if sso {
var role types.UserRole
if h.DbTx(rw, func(tx *database.Queries) (err error) {
role, err = tx.GetUserRole(req.Context(), auth.ID)
role, err = tx.GetUserRole(req.Context(), auth.Subject)
return
}) {
return
@ -107,7 +107,7 @@ func (h *HttpServer) ManageAppsPost(rw http.ResponseWriter, req *http.Request, _
Name: name,
Secret: secret,
Domain: domain,
Owner: auth.ID,
Owner: auth.Subject,
Public: public,
Sso: sso,
Active: active,
@ -124,7 +124,7 @@ func (h *HttpServer) ManageAppsPost(rw http.ResponseWriter, req *http.Request, _
Sso: sso,
Active: active,
Subject: req.FormValue("subject"),
Owner: auth.ID,
Owner: auth.Subject,
})
}) {
return
@ -145,7 +145,7 @@ func (h *HttpServer) ManageAppsPost(rw http.ResponseWriter, req *http.Request, _
err = tx.ResetClientAppSecret(req.Context(), database.ResetClientAppSecretParams{
Secret: secret,
Subject: sub,
Owner: auth.ID,
Owner: auth.Subject,
})
return err
}) {

View File

@ -31,7 +31,7 @@ func (h *HttpServer) ManageUsersGet(rw http.ResponseWriter, req *http.Request, _
var role types.UserRole
var userList []database.GetUserListRow
if h.DbTx(rw, func(tx *database.Queries) (err error) {
role, err = tx.GetUserRole(req.Context(), auth.ID)
role, err = tx.GetUserRole(req.Context(), auth.Subject)
if err != nil {
return
}
@ -51,7 +51,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,
"Namespace": h.conf.Namespace,
}
if q.Has("edit") {
@ -80,7 +80,7 @@ func (h *HttpServer) ManageUsersPost(rw http.ResponseWriter, req *http.Request,
var role types.UserRole
if h.DbTx(rw, func(tx *database.Queries) (err error) {
role, err = tx.GetUserRole(req.Context(), auth.ID)
role, err = tx.GetUserRole(req.Context(), auth.Subject)
return
}) {
return

View File

@ -80,11 +80,11 @@ func (h *HttpServer) authorizeEndpoint(rw http.ResponseWriter, req *http.Request
var user string
var hasOtp bool
if h.DbTx(rw, func(tx *database.Queries) (err error) {
user, err = tx.GetUserDisplayName(req.Context(), auth.ID)
user, err = tx.GetUserDisplayName(req.Context(), auth.Subject)
if err != nil {
return
}
hasOtp, err = tx.HasOtp(req.Context(), auth.ID)
hasOtp, err = tx.HasOtp(req.Context(), auth.Subject)
return
}) {
return
@ -117,7 +117,7 @@ func (h *HttpServer) authorizeEndpoint(rw http.ResponseWriter, req *http.Request
if !isSSO {
otpInput := req.FormValue("code")
if h.fetchAndValidateOtp(rw, auth.ID, otpInput) {
if h.fetchAndValidateOtp(rw, auth.Subject, otpInput) {
return
}
}
@ -147,7 +147,7 @@ func (h *HttpServer) oauthUserAuthorization(rw http.ResponseWriter, req *http.Re
return "", err
}
auth, err := h.internalAuthenticationHandler(req)
auth, err := h.internalAuthenticationHandler(rw, req)
if err != nil {
return "", err
}
@ -169,5 +169,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
}

View File

@ -34,7 +34,7 @@ func (h *HttpServer) LoginOtpPost(rw http.ResponseWriter, req *http.Request, _ h
}
otpInput := req.FormValue("code")
if h.fetchAndValidateOtp(rw, auth.ID, otpInput) {
if h.fetchAndValidateOtp(rw, auth.Subject, otpInput) {
return
}
@ -86,13 +86,13 @@ func (h *HttpServer) EditOtpPost(rw http.ResponseWriter, req *http.Request, _ ht
}
otpInput := req.Form.Get("code")
if h.fetchAndValidateOtp(rw, auth.ID, otpInput) {
if h.fetchAndValidateOtp(rw, auth.Subject, otpInput) {
return
}
if h.DbTx(rw, func(tx *database.Queries) error {
return tx.SetOtp(req.Context(), database.SetOtpParams{
Subject: auth.ID,
Subject: auth.Subject,
Secret: "",
Digits: 0,
})
@ -128,7 +128,7 @@ func (h *HttpServer) EditOtpPost(rw http.ResponseWriter, req *http.Request, _ ht
var email string
if h.DbTx(rw, func(tx *database.Queries) error {
var err error
email, err = tx.GetUserEmail(req.Context(), auth.ID)
email, err = tx.GetUserEmail(req.Context(), auth.Subject)
return err
}) {
return
@ -177,7 +177,7 @@ func (h *HttpServer) EditOtpPost(rw http.ResponseWriter, req *http.Request, _ ht
if h.DbTx(rw, func(tx *database.Queries) error {
return tx.SetOtp(req.Context(), database.SetOtpParams{
Subject: auth.ID,
Subject: auth.Subject,
Secret: secret,
Digits: int64(digits),
})

View File

@ -124,7 +124,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: "tulip-login-data",
Name: "tulip-login-access",
Path: "/",
MaxAge: -1,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
http.SetCookie(rw, &http.Cookie{
Name: "tulip-login-refresh",
Path: "/",
MaxAge: -1,
Secure: true,