I got to this point before starting again

This commit is contained in:
Melon 2025-02-24 17:17:28 +00:00
parent 71a746bd73
commit 80d3298813
Signed by: melon
GPG Key ID: 6C9D970C50D26A25
18 changed files with 179 additions and 97 deletions

View File

@ -2,36 +2,14 @@ package auth
import (
"context"
"github.com/1f349/lavender/auth/process"
"github.com/1f349/lavender/database"
)
// State defines the currently reached authentication state
type State byte
const (
// StateUnauthorized defines the "unauthorized" state of a session
StateUnauthorized State = iota
// StateBase defines the "username" only user state
// This state is for providing a username to allow redirecting to oauth clients
StateBase
// StateBasic defines the "username and password with no OTP" user state
// This is skipped if OTP/passkey is optional and not enabled for the user
StateBasic
// StateExtended defines the "logged in" user state
StateExtended
// StateSudo defines the "sudo" user state
// This state is temporary and has a configurable duration
StateSudo
)
func (s State) IsLoggedIn() bool { return s >= StateExtended }
func (s State) IsSudoAvailable() bool { return s == StateSudo }
type Provider interface {
// AccessState defines the state at which the provider is allowed to show.
// Some factors might be unavailable due to user preference.
AccessState() State
AccessState() process.State
// Name defines a string value for the provider.
Name() string

View File

@ -1,17 +1,18 @@
package authContext
import (
"github.com/1f349/lavender/auth/login-process"
"github.com/1f349/lavender/auth/process"
"github.com/1f349/lavender/database"
"net/http"
)
func NewFormContext(req *http.Request, user *database.User) *BaseFormContext {
func NewFormContext(req *http.Request, user *database.User, rw http.ResponseWriter) *BaseFormContext {
return &BaseFormContext{
BaseTemplateContext: BaseTemplateContext{
req: req,
user: user,
},
rw: rw,
}
}
@ -19,6 +20,7 @@ type FormContext interface {
TemplateContext
SetUser(user *database.User)
UpdateSession(data process.LoginProcessData)
ResponseWriter() http.ResponseWriter
__formContext()
}
@ -27,6 +29,11 @@ var _ FormContext = &BaseFormContext{}
type BaseFormContext struct {
BaseTemplateContext
loginProcessData process.LoginProcessData
rw http.ResponseWriter
}
func (b *BaseFormContext) GetLoginProcessData() process.LoginProcessData {
return b.loginProcessData
}
func (b *BaseFormContext) SetUser(user *database.User) {
@ -37,4 +44,8 @@ func (b *BaseFormContext) UpdateSession(data process.LoginProcessData) {
b.loginProcessData = data
}
func (b *BaseFormContext) ResponseWriter() http.ResponseWriter {
return b.rw
}
func (b *BaseFormContext) __formContext() {}

View File

@ -1,12 +1,14 @@
package process
import "github.com/1f349/mjwt"
import (
"github.com/1f349/mjwt"
)
var _ mjwt.Claims = (*LoginProcessData)(nil)
// TODO: add some actual session management
type LoginProcessData struct {
State byte
State State
}
func (d LoginProcessData) Valid() error { return nil }

24
auth/process/state.go Normal file
View File

@ -0,0 +1,24 @@
package process
// State defines the currently reached authentication state
type State byte
const (
// StateUnauthorized defines the "unauthorized" state of a session
StateUnauthorized State = iota
// StateBase defines the "username" only user state
// This state is for providing a username to allow redirecting to oauth clients
StateBase
// StateBasic defines the "username and password with no OTP" user state
// This is skipped if OTP/passkey is optional and not enabled for the user
StateBasic
// StateExtended defines the "logged in" user state
StateExtended
// StateSudo defines the "sudo" user state
// This state is temporary and has a configurable duration
StateSudo
)
func (s State) IsLoggedIn() bool { return s >= StateExtended }
func (s State) IsSudoAvailable() bool { return s == StateSudo }

View File

@ -3,18 +3,25 @@ package providers
import (
"github.com/1f349/lavender/auth"
"github.com/1f349/lavender/auth/authContext"
process "github.com/1f349/lavender/auth/process"
"github.com/1f349/lavender/database"
"github.com/1f349/lavender/logger"
"net/http"
"time"
)
var _ auth.Provider = (*InitialLogin)(nil)
var _ auth.Form = (*InitialLogin)(nil)
type InitialLogin struct{}
type InitialLogin struct {
DB *database.Queries
}
func (m *InitialLogin) AccessState() auth.State { return auth.StateUnauthorized }
func (m *InitialLogin) AccessState() process.State { return process.StateUnauthorized }
func (m *InitialLogin) Name() string { return "base" }
func (m *InitialLogin) RenderTemplate(ctx authContext.FormContext) error {
func (m *InitialLogin) RenderTemplate(ctx authContext.TemplateContext) error {
type s struct {
UserEmail string
Redirect string
@ -43,5 +50,21 @@ func (m *InitialLogin) AttemptLogin(ctx authContext.FormContext) error {
userEmail := req.FormValue("email")
rememberMe := req.FormValue("remember-me")
logger.Logger.Debug("Hi", "em", userEmail, "rm", rememberMe)
rw := ctx.ResponseWriter()
now := time.Now()
future := now.AddDate(1, 0, 0)
http.SetCookie(rw, &http.Cookie{
Name: "lavender-user-memory",
Value: userEmail,
Path: "/",
Expires: future,
MaxAge: int(future.Sub(now).Seconds()),
Secure: true,
SameSite: http.SameSiteLaxMode,
})
ctx.UpdateSession(process.LoginProcessData{State: process.StateBase})
return nil
}

View File

@ -9,6 +9,7 @@ import (
"github.com/1f349/cache"
"github.com/1f349/lavender/auth"
"github.com/1f349/lavender/auth/authContext"
"github.com/1f349/lavender/auth/process"
"github.com/1f349/lavender/database"
"github.com/1f349/lavender/database/types"
"github.com/1f349/lavender/issuer"
@ -52,7 +53,7 @@ func (o OAuthLogin) authUrlBase(ref string) *url.URL {
return o.BaseUrl.Resolve("oauth", o.Name(), ref)
}
func (o OAuthLogin) AccessState() auth.State { return auth.StateUnauthorized }
func (o OAuthLogin) AccessState() process.State { return process.StateUnauthorized }
func (o OAuthLogin) Name() string { return "oauth" }
@ -154,7 +155,7 @@ func (o OAuthLogin) updateExternalUserInfo(req *http.Request, sso *issuer.WellKn
})
return auth.UserAuth{
Subject: userSubject,
Factor: auth.StateExtended,
Factor: process.StateExtended,
UserInfo: sessionData.UserInfo,
}, err
case errors.Is(err, sql.ErrNoRows):
@ -210,7 +211,7 @@ func (o OAuthLogin) updateExternalUserInfo(req *http.Request, sso *issuer.WellKn
// TODO(melon): this feels bad
sessionData = auth.UserAuth{
Subject: userSubject,
Factor: auth.StateExtended,
Factor: process.StateExtended,
UserInfo: sessionData.UserInfo,
}
@ -275,7 +276,7 @@ func (o OAuthLogin) fetchUserInfo(sso *issuer.WellKnownOIDC, token *oauth2.Token
return auth.UserAuth{
Subject: subject,
Factor: auth.StateExtended,
Factor: process.StateExtended,
UserInfo: userInfoJson,
}, nil
}

View File

@ -6,6 +6,7 @@ import (
"fmt"
"github.com/1f349/lavender/auth"
"github.com/1f349/lavender/auth/authContext"
"github.com/1f349/lavender/auth/process"
"github.com/1f349/lavender/database"
"github.com/xlzd/gotp"
"net/http"
@ -26,7 +27,7 @@ type OtpLogin struct {
DB otpLoginDB
}
func (o *OtpLogin) AccessState() auth.State { return auth.StateBasic }
func (o *OtpLogin) AccessState() process.State { return process.StateBasic }
func (o *OtpLogin) Name() string { return "basic" }

View File

@ -3,6 +3,7 @@ package providers
import (
"github.com/1f349/lavender/auth"
"github.com/1f349/lavender/auth/authContext"
"github.com/1f349/lavender/auth/process"
)
type passkeyLoginDB interface {
@ -18,7 +19,7 @@ type PasskeyLogin struct {
DB passkeyLoginDB
}
func (p *PasskeyLogin) AccessState() auth.State { return auth.StateUnauthorized }
func (p *PasskeyLogin) AccessState() process.State { return process.StateUnauthorized }
func (p *PasskeyLogin) Name() string { return "passkey" }

View File

@ -6,6 +6,7 @@ import (
"errors"
"github.com/1f349/lavender/auth"
"github.com/1f349/lavender/auth/authContext"
"github.com/1f349/lavender/auth/process"
"github.com/1f349/lavender/database"
"net/http"
)
@ -24,7 +25,7 @@ type PasswordLogin struct {
DB passwordLoginDB
}
func (b *PasswordLogin) AccessState() auth.State { return auth.StateBase }
func (b *PasswordLogin) AccessState() process.State { return process.StateBase }
func (b *PasswordLogin) Name() string { return "password" }

View File

@ -1,6 +1,7 @@
package auth
import (
"github.com/1f349/lavender/auth/process"
"github.com/julienschmidt/httprouter"
"net/http"
"net/url"
@ -11,7 +12,7 @@ type UserHandler func(rw http.ResponseWriter, req *http.Request, params httprout
type UserAuth struct {
Subject string
Factor State
Factor process.State
UserInfo UserInfoFields
}

View File

@ -3,6 +3,7 @@ package server
import (
"context"
"github.com/1f349/lavender/auth"
"github.com/1f349/lavender/auth/process"
"github.com/1f349/mjwt"
"github.com/golang-jwt/jwt/v4"
"github.com/google/uuid"
@ -18,7 +19,7 @@ func TestUserAuth_NextFlowUrl(t *testing.T) {
assert.Equal(t, url.URL{Path: "/login"}, *u.NextFlowUrl(&url.URL{}))
assert.Equal(t, url.URL{Path: "/login", RawQuery: url.Values{"redirect": {"/hello"}}.Encode()}, *u.NextFlowUrl(&url.URL{Path: "/hello"}))
assert.Equal(t, url.URL{Path: "/login", RawQuery: url.Values{"redirect": {"/hello?a=A"}}.Encode()}, *u.NextFlowUrl(&url.URL{Path: "/hello", RawQuery: url.Values{"a": {"A"}}.Encode()}))
u.Factor = auth.StateExtended
u.Factor = process.StateExtended
assert.Nil(t, u.NextFlowUrl(&url.URL{}))
}

View File

@ -3,11 +3,11 @@ package server
import (
"bytes"
"context"
"database/sql"
"errors"
"fmt"
"github.com/1f349/lavender/auth"
"github.com/1f349/lavender/auth/authContext"
"github.com/1f349/lavender/auth/process"
"github.com/1f349/lavender/auth/providers"
"github.com/1f349/lavender/database"
"github.com/1f349/lavender/issuer"
@ -43,7 +43,7 @@ func getUserLoginName(req *http.Request) string {
return originUrl.Query().Get("login_name")
}
func (h *httpServer) getAuthWithState(state auth.State) auth.Provider {
func (h *httpServer) getAuthWithState(state process.State) auth.Provider {
for _, i := range h.authSources {
if i.AccessState() == state {
return i
@ -75,30 +75,6 @@ func (h *httpServer) loginGet(rw http.ResponseWriter, req *http.Request, _ httpr
// TODO: some of this should be more like tulip
cookie, err := req.Cookie("lavender-login-name")
if err == nil && cookie.Valid() == nil {
loginName := cookie.Value
_, err := h.db.GetUser(req.Context(), userAuth.Subject)
switch {
case err == nil:
break
case errors.Is(err, sql.ErrNoRows):
loginName = ""
default:
http.Error(rw, "Internal server error", http.StatusInternalServerError)
return
}
web.RenderPageTemplate(rw, "login-memory", map[string]any{
"ServiceName": h.conf.ServiceName,
"LoginName": loginName,
"Redirect": req.URL.Query().Get("redirect"),
"Source": "start",
})
return
}
buttonCtx := authContext.NewTemplateContext(req, new(database.User))
buttonTemplates := make([]template.HTML, 0, len(h.authButtons))
@ -113,13 +89,17 @@ func (h *httpServer) loginGet(rw http.ResponseWriter, req *http.Request, _ httpr
}
}
type loginError struct {
Error string `json:"error"`
authState := process.StateUnauthorized
jwtCookie, err := readJwtCookie[process.LoginProcessData](req, "login-process", h.signingKey.KeyStore())
if err == nil {
authState = jwtCookie.Claims.State
return
}
var renderTemplate template.HTML
provider := h.getAuthWithState(authState)
provider := h.getAuthWithState(auth.StateUnauthorized)
var renderTemplate template.HTML
// Maybe the admin has disabled some login providers but does have a button based provider available?
form, ok := provider.(auth.Form)
@ -127,7 +107,9 @@ func (h *httpServer) loginGet(rw http.ResponseWriter, req *http.Request, _ httpr
renderTemplate, err = h.renderAuthTemplate(req, form)
if err != nil {
logger.Logger.Warn("No provider for login")
web.RenderPageTemplate(rw, "login-error", loginError{Error: "No available provider for login"})
web.RenderPageTemplate(rw, "login-error", struct {
Error string `json:"error"`
}{Error: "No available provider for login"})
return
}
}
@ -165,6 +147,7 @@ func (h *httpServer) loginPost(rw http.ResponseWriter, req *http.Request, _ http
}).String(), http.StatusFound)
return
}
loginName := req.PostFormValue("email")
// append local namespace if @ is missing
@ -206,16 +189,16 @@ func (h *httpServer) loginPost(rw http.ResponseWriter, req *http.Request, _ http
// TODO(melon): rewrite login system here
// if the login is the local server
if login == issuer.MeWellKnown {
// TODO(melon): work on this
// TODO: rewrite
//err := h.authBasic.AttemptLogin(ctx, req, nil)
var err error
switch {
case errors.As(err, &redirectError):
http.Redirect(rw, req, redirectError.Target, redirectError.Code)
return
}
if login != issuer.MeWellKnown {
// save state for use later
state := login.Config.Namespace + ":" + uuid.NewString()
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
oa2conf.RedirectURL = h.conf.BaseUrl.JoinPath("callback").String()
nextUrl := oa2conf.AuthCodeURL(state, oauth2.SetAuthURLParam("login_name", loginUn))
http.Redirect(rw, req, nextUrl, http.StatusFound)
return
}
@ -238,29 +221,68 @@ func (h *httpServer) loginPost(rw http.ResponseWriter, req *http.Request, _ http
}
// TODO: rewrite
formContext := authContext.NewFormContext(req, nil)
formContext := authContext.NewFormContext(req, nil, rw)
err := authForm.AttemptLogin(formContext)
switch {
case errors.As(err, &redirectError):
http.Redirect(rw, req, redirectError.Target, redirectError.Code)
return
}
// TODO: idk why login process data isn't working properly
processData := formContext.GetLoginProcessData()
if h.setLoginProcessCookie(rw, processData) {
return
}
// TODO: figure this out
logger.Logger.Debug("POST /login: form render data: ", formContext.Data())
http.Redirect(rw, req, h.conf.BaseUrl.JoinPath("login").String(), http.StatusFound)
}
func (h *httpServer) setLoginProcessCookie(rw http.ResponseWriter, data process.LoginProcessData) bool {
gen, err := h.signingKey.GenerateJwt("login-process", uuid.NewString(), jwt.ClaimStrings{h.conf.BaseUrl.String()}, time.Hour, data)
if err != nil {
http.Error(rw, "Failed to generate cookie token", http.StatusInternalServerError)
return true
}
http.SetCookie(rw, &http.Cookie{
Name: "lavender-login-process",
Value: gen,
Path: "/",
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
return false
}
func (h *httpServer) loginCallback(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, _ auth.UserAuth) {
// TODO: rewrite
for _, i := range h.authSources {
if callback, ok := i.(authContext.CallbackContext); ok {
callback.HandleCallback(rw, req)
user := callback.User()
h.setLoginDataCookie(rw, auth.UserAuth{
Subject: user.Subject,
Factor: auth.StateExtended,
UserInfo: auth.UserInfoFields{},
}, "loginName")
break
}
flowState, ok := h.flowState.Get(req.FormValue("state"))
if !ok {
http.Error(rw, "Invalid flow state", http.StatusBadRequest)
return
}
token, err := flowState.sso.OAuth2Config.Exchange(context.Background(), req.FormValue("code"), oauth2.SetAuthURLParam("redirect_uri", h.conf.BaseUrl.JoinPath("callback").String()))
if err != nil {
http.Error(rw, "Failed to exchange code for token", http.StatusInternalServerError)
return
}
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.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)
}
utils.SafeRedirect(rw, req)
}
const twelveHours = 12 * time.Hour
@ -268,7 +290,7 @@ const oneWeek = 7 * 24 * time.Hour
type lavenderLoginAccess struct {
UserInfo auth.UserInfoFields `json:"user_info"`
Factor auth.State `json:"factor"`
Factor process.State `json:"factor"`
mjwtAuth.AccessTokenClaims
}

View File

@ -35,10 +35,19 @@ type httpServer struct {
// mailLinkCache contains a mapping of verify uuids to user uuids
mailLinkCache *cache.Cache[mailLinkKey, string]
// flowState contains the flow state of 3rd party oauth2
flowState *cache.Cache[string, flowStateData]
authSources []auth.Provider
authButtons []auth.Button
}
type flowStateData struct {
loginName string
sso *issuer.WellKnownOIDC
redirect string
}
type mailLink byte
const (
@ -55,14 +64,16 @@ type mailLinkKey struct {
func SetupRouter(r *httprouter.Router, config conf.Conf, mailSender *mail.Mail, db *database.Queries, signingKey *mjwt.Issuer) {
// TODO: move auth provider init to main function
// TODO: allow dynamically changing the providers based on database information
authBasic := &providers.PasswordLogin{DB: db}
authInitial := &providers.InitialLogin{}
authPassword := &providers.PasswordLogin{DB: db}
authOtp := &providers.OtpLogin{DB: db}
authOAuth := &providers.OAuthLogin{DB: db, BaseUrl: &config.BaseUrl}
authOAuth.Init()
authPasskey := &providers.PasskeyLogin{DB: db}
authSources := []auth.Provider{
authBasic,
authInitial,
authPassword,
authOtp,
authOAuth,
authPasskey,

View File

@ -3,6 +3,7 @@ export const partial = true;
---
<form method="POST" action="/login">
<input type="hidden" name="provider" value="base"/>
<input type="hidden" name="redirect" value="[[.Redirect]]"/>
<div>
<label for="field_email">Email:</label>

View File

@ -3,6 +3,7 @@ export const partial = true;
---
<form method="POST" action="/login">
<input type="hidden" name="provider" value="oauth"/>
<input type="hidden" name="redirect" value="[[.Redirect]]"/>
<div>
<label for="field_loginname">Login Name:</label>

View File

@ -3,6 +3,7 @@ export const partial = true;
---
<form method="POST" action="/login/otp" autocomplete="off">
<input type="hidden" name="provider" value="otp"/>
<input type="hidden" name="redirect" value="[[.Redirect]]"/>
<div>
<label for="field_code">OTP Code:</label>

View File

@ -3,6 +3,7 @@ export const partial = true;
---
<form method="POST" action="/login">
<input type="hidden" name="provider" value="password"/>
<input type="hidden" name="redirect" value="[[.Redirect]]"/>
<input type="hidden" name="email" value="[[.UserEmail]]"/>
<div>

View File

@ -11,6 +11,7 @@ import Layout from "../layouts/Layout.astro";
[[ .AuthTemplate ]]
<div class="w-full">
<form method="POST" action="/login">
<input type="hidden" name="provider" value="base"/>
<input type="hidden" name="redirect" value="[[ .Redirect ]]"/>
<div class="w-full">
<label>Email: <input type="email" name="email" value="[[ .UserEmail ]]" required/></label>