Improved login process and allow hijacking the login process

This commit is contained in:
Melon 2025-03-01 16:54:58 +00:00
parent 80d3298813
commit d9b0074133
Signed by: melon
GPG Key ID: 6C9D970C50D26A25
10 changed files with 205 additions and 193 deletions

View File

@ -20,8 +20,10 @@ type FormContext interface {
TemplateContext
SetUser(user *database.User)
UpdateSession(data process.LoginProcessData)
GetLoginProcessData() process.LoginProcessData
ResponseWriter() http.ResponseWriter
__formContext()
Hijack()
}
var _ FormContext = &BaseFormContext{}
@ -30,22 +32,19 @@ type BaseFormContext struct {
BaseTemplateContext
loginProcessData process.LoginProcessData
rw http.ResponseWriter
hijack bool
}
func (b *BaseFormContext) GetLoginProcessData() process.LoginProcessData {
return b.loginProcessData
}
func (b *BaseFormContext) GetLoginProcessData() process.LoginProcessData { return b.loginProcessData }
func (b *BaseFormContext) SetUser(user *database.User) {
b.BaseTemplateContext.user = user
}
func (b *BaseFormContext) SetUser(user *database.User) { b.BaseTemplateContext.user = user }
func (b *BaseFormContext) UpdateSession(data process.LoginProcessData) {
b.loginProcessData = data
}
func (b *BaseFormContext) UpdateSession(data process.LoginProcessData) { b.loginProcessData = data }
func (b *BaseFormContext) ResponseWriter() http.ResponseWriter {
return b.rw
}
func (b *BaseFormContext) ResponseWriter() http.ResponseWriter { return b.rw }
func (b *BaseFormContext) __formContext() {}
func (b *BaseFormContext) Hijack() { b.hijack = true }
func (b *BaseFormContext) HijackCalled() bool { return b.hijack }

View File

@ -2,14 +2,16 @@ package authContext
import (
"context"
"github.com/1f349/lavender/auth/process"
"github.com/1f349/lavender/database"
"net/http"
)
func NewTemplateContext(req *http.Request, user *database.User) *BaseTemplateContext {
func NewTemplateContext(req *http.Request, user *database.User, processData process.LoginProcessData) *BaseTemplateContext {
return &BaseTemplateContext{
req: req,
user: user,
req: req,
user: user,
processData: processData,
}
}
@ -18,15 +20,17 @@ type TemplateContext interface {
Request() *http.Request
User() *database.User
Render(data any)
LoginProcessData() process.LoginProcessData
__templateContext()
}
var _ TemplateContext = &BaseTemplateContext{}
type BaseTemplateContext struct {
req *http.Request
user *database.User
data any
req *http.Request
user *database.User
processData process.LoginProcessData
data any
}
func (t *BaseTemplateContext) Context() context.Context { return t.req.Context() }
@ -37,6 +41,8 @@ func (t *BaseTemplateContext) User() *database.User { return t.user }
func (t *BaseTemplateContext) Render(data any) { t.data = data }
func (t *BaseTemplateContext) LoginProcessData() process.LoginProcessData { return t.processData }
func (t *BaseTemplateContext) Data() any { return t.data }
func (t *BaseTemplateContext) __templateContext() {}

View File

@ -6,9 +6,15 @@ import (
var _ mjwt.Claims = (*LoginProcessData)(nil)
// LoginProcessData stores the current state and relevant information during the
// process of a login. This data is sent signed but unencrypted to the user's
// device. For this reason, all fields must only contain user input or generic
// enum state data.
//
// TODO: add some actual session management
type LoginProcessData struct {
State State
Email string
}
func (d LoginProcessData) Valid() error { return nil }

View File

@ -19,6 +19,10 @@ const (
StateSudo
)
func (s State) IsValid() bool {
return s <= StateSudo
}
func (s State) IsLoggedIn() bool { return s >= StateExtended }
func (s State) IsSudoAvailable() bool { return s == StateSudo }

View File

@ -1,20 +1,30 @@
package providers
import (
"errors"
"fmt"
"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/issuer"
"github.com/1f349/lavender/logger"
"github.com/google/uuid"
"golang.org/x/oauth2"
"net/http"
"strings"
"time"
)
const memoryCookieName = "lavender-user-memory"
var _ auth.Provider = (*InitialLogin)(nil)
var _ auth.Form = (*InitialLogin)(nil)
type InitialLogin struct {
DB *database.Queries
DB *database.Queries
MyNamespace string
Manager *issuer.Manager
}
func (m *InitialLogin) AccessState() process.State { return process.StateUnauthorized }
@ -23,23 +33,24 @@ func (m *InitialLogin) Name() string { return "base" }
func (m *InitialLogin) RenderTemplate(ctx authContext.TemplateContext) error {
type s struct {
UserEmail string
Redirect string
LoginNameMemory bool
UserEmail string
Redirect string
}
req := ctx.Request()
q := req.URL.Query()
cookie, err := req.Cookie("lavender-user-memory")
if err == nil && cookie.Valid() == nil {
ctx.Render(s{
UserEmail: cookie.Value,
Redirect: q.Get("redirect"),
})
return nil
userEmail := ctx.LoginProcessData().Email
if userEmail == "" {
cookie, err := req.Cookie(memoryCookieName)
if err == nil && cookie.Valid() == nil {
userEmail = cookie.Value
}
}
ctx.Render(s{
UserEmail: "",
UserEmail: userEmail,
Redirect: q.Get("redirect"),
})
return nil
@ -47,24 +58,85 @@ func (m *InitialLogin) RenderTemplate(ctx authContext.TemplateContext) error {
func (m *InitialLogin) AttemptLogin(ctx authContext.FormContext) error {
req := ctx.Request()
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})
if req.FormValue("not-you") != "" {
http.SetCookie(rw, &http.Cookie{
Name: memoryCookieName,
Value: "",
Path: "/",
MaxAge: -1,
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
return auth.RedirectError{
Target: "/login",
Code: http.StatusFound,
}
}
loginName := req.FormValue("email")
if loginName == "" {
return errors.New("email required")
}
rememberMe := req.FormValue("remember-me") != ""
logger.Logger.Debug("Hi", "login", loginName, "remember", rememberMe)
// Set the remember me cookie
if rememberMe {
now := time.Now()
future := now.AddDate(1, 0, 0)
http.SetCookie(rw, &http.Cookie{
Name: memoryCookieName,
Value: loginName,
Path: "/",
Expires: future,
MaxAge: int(future.Sub(now).Seconds()),
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}
// append local namespace if @ is missing
n := strings.IndexByte(loginName, '@')
if n < 0 {
// correct the @ index
n = len(loginName)
loginName += "@" + m.MyNamespace
}
login := m.Manager.FindServiceFromLogin(loginName)
if login == nil {
http.Error(rw, "No login service defined for this username", http.StatusBadRequest)
return errors.New("no login service defined for this username")
}
// the @ must exist if the service is defined
loginUn := loginName[:n]
// TODO: finish migrating this shit
// the login is not for this namespace
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
}
ctx.UpdateSession(process.LoginProcessData{
Email: loginName,
State: process.StateBase,
})
return nil
}

View File

@ -8,7 +8,6 @@ import (
"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"
"github.com/1f349/lavender/logger"
@ -23,7 +22,6 @@ import (
"html/template"
"net/http"
"net/url"
"strings"
"time"
)
@ -52,8 +50,8 @@ func (h *httpServer) getAuthWithState(state process.State) auth.Provider {
return nil
}
func (h *httpServer) renderAuthTemplate(req *http.Request, provider auth.Form) (template.HTML, error) {
tmpCtx := authContext.NewTemplateContext(req, new(database.User))
func (h *httpServer) renderAuthTemplate(req *http.Request, provider auth.Form, processData process.LoginProcessData) (template.HTML, error) {
tmpCtx := authContext.NewTemplateContext(req, new(database.User), processData)
err := provider.RenderTemplate(tmpCtx)
if err != nil {
@ -73,9 +71,16 @@ func (h *httpServer) loginGet(rw http.ResponseWriter, req *http.Request, _ httpr
return
}
var processData process.LoginProcessData
jwtCookie, err := readJwtCookie[process.LoginProcessData](req, "lavender-login-process", h.signingKey.KeyStore())
if err == nil {
processData = jwtCookie.Claims
}
// TODO: some of this should be more like tulip
buttonCtx := authContext.NewTemplateContext(req, new(database.User))
buttonCtx := authContext.NewTemplateContext(req, new(database.User), processData)
buttonTemplates := make([]template.HTML, 0, len(h.authButtons))
for i := range h.authButtons {
@ -89,22 +94,14 @@ func (h *httpServer) loginGet(rw http.ResponseWriter, req *http.Request, _ httpr
}
}
authState := process.StateUnauthorized
jwtCookie, err := readJwtCookie[process.LoginProcessData](req, "login-process", h.signingKey.KeyStore())
if err == nil {
authState = jwtCookie.Claims.State
return
}
provider := h.getAuthWithState(authState)
provider := h.getAuthWithState(processData.State)
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)
if provider != nil && ok {
renderTemplate, err = h.renderAuthTemplate(req, form)
renderTemplate, err = h.renderAuthTemplate(req, form, processData)
if err != nil {
logger.Logger.Warn("No provider for login")
web.RenderPageTemplate(rw, "login-error", struct {
@ -131,106 +128,40 @@ func (h *httpServer) loginPost(rw http.ResponseWriter, req *http.Request, _ http
return
}
// TODO: some of this should be more like tulip
if req.PostFormValue("not-you") == "1" {
http.SetCookie(rw, &http.Cookie{
Name: "lavender-login-name",
Value: "",
Path: "/",
MaxAge: -1,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
http.Redirect(rw, req, (&url.URL{
Path: "/login",
}).String(), http.StatusFound)
return
}
loginName := req.PostFormValue("email")
// append local namespace if @ is missing
n := strings.IndexByte(loginName, '@')
if n < 0 {
// correct the @ index
n = len(loginName)
loginName += "@" + h.conf.Namespace
}
login := h.manager.FindServiceFromLogin(loginName)
if login == nil {
http.Error(rw, "No login service defined for this username", http.StatusBadRequest)
return
}
// the @ must exist if the service is defined
loginUn := loginName[:n]
ctx := providers.WithWellKnown(req.Context(), login)
ctx = context.WithValue(ctx, "login_username", loginUn)
ctx = context.WithValue(ctx, "login_full", loginName)
// TODO(melon): only do if remember-me is enabled
now := time.Now()
future := now.AddDate(1, 0, 0)
http.SetCookie(rw, &http.Cookie{
Name: "lavender-login-name",
Value: loginName,
Path: "/",
Expires: future,
MaxAge: int(future.Sub(now).Seconds()),
Secure: true,
SameSite: http.SameSiteLaxMode,
})
var redirectError auth.RedirectError
// TODO(melon): rewrite login system here
// if the login is the local server
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
}
var authForm auth.Form
{
for _, i := range h.authSources {
if form, ok := i.(auth.Form); ok {
if req.PostFormValue("provider") == form.Name() {
authForm = form
break
}
}
}
var processData process.LoginProcessData
jwtCookie, err := readJwtCookie[process.LoginProcessData](req, "lavender-login-process", h.signingKey.KeyStore())
if err == nil {
processData = jwtCookie.Claims
}
authForm := h.formProviderLookup[req.PostFormValue("provider")]
if authForm == nil {
http.Error(rw, "Invalid auth provider", http.StatusBadRequest)
return
}
if processData.State != authForm.AccessState() {
http.Redirect(rw, req, "/login", http.StatusFound)
return
}
// TODO: rewrite
formContext := authContext.NewFormContext(req, nil, rw)
err := authForm.AttemptLogin(formContext)
switch {
case errors.As(err, &redirectError):
err = authForm.AttemptLogin(formContext)
var redirectError auth.RedirectError
if errors.As(err, &redirectError) {
http.Redirect(rw, req, redirectError.Target, redirectError.Code)
return
}
// ResponseWriter has been hijacked so we stop processing here
if formContext.HijackCalled() {
return
}
// TODO: idk why login process data isn't working properly
processData := formContext.GetLoginProcessData()
processData = formContext.GetLoginProcessData()
if h.setLoginProcessCookie(rw, processData) {
return
}

View File

@ -38,8 +38,9 @@ type httpServer struct {
// flowState contains the flow state of 3rd party oauth2
flowState *cache.Cache[string, flowStateData]
authSources []auth.Provider
authButtons []auth.Button
authSources []auth.Provider
authButtons []auth.Button
formProviderLookup map[string]auth.Form
}
type flowStateData struct {
@ -71,20 +72,6 @@ func SetupRouter(r *httprouter.Router, config conf.Conf, mailSender *mail.Mail,
authOAuth.Init()
authPasskey := &providers.PasskeyLogin{DB: db}
authSources := []auth.Provider{
authInitial,
authPassword,
authOtp,
authOAuth,
authPasskey,
}
authButtons := make([]auth.Button, 0)
for _, source := range authSources {
if button, isButton := source.(auth.Button); isButton {
authButtons = append(authButtons, button)
}
}
hs := &httpServer{
r: r,
db: db,
@ -94,8 +81,26 @@ func SetupRouter(r *httprouter.Router, config conf.Conf, mailSender *mail.Mail,
mailLinkCache: cache.New[mailLinkKey, string](),
authSources: authSources,
authButtons: authButtons,
authSources: []auth.Provider{
authInitial,
authPassword,
authOtp,
authOAuth,
authPasskey,
},
authButtons: make([]auth.Button, 0),
formProviderLookup: make(map[string]auth.Form),
}
// build slices and maps for quick access to auth interfaces
for _, source := range hs.authSources {
if button, isButton := source.(auth.Button); isButton {
hs.authButtons = append(hs.authButtons, button)
}
if form, isForm := source.(auth.Form); isForm {
hs.formProviderLookup[form.Name()] = form
}
}
var err error

View File

@ -2,6 +2,23 @@
export const partial = true;
---
[[ if .LoginNameMemory ]]
<div>Login in as: <span>[[ .UserEmail ]]</span></div>
<div>
<form method="POST" action="/login">
<input type="hidden" name="provider" value="base"/>
<button type="submit" name="not-you" value="1">Not You?</button>
</form>
</div>
<div>
<form method="POST" action="/login">
<input type="hidden" name="provider" value="base"/>
<input type="hidden" name="redirect" value="[[ .Redirect ]]"/>
<input type="hidden" name="email" value="[[ .UserEmail ]]"/>
<button type="submit">Continue</button>
</form>
</div>
[[ else ]]
<form method="POST" action="/login">
<input type="hidden" name="provider" value="base"/>
<input type="hidden" name="redirect" value="[[.Redirect]]"/>
@ -14,3 +31,4 @@ export const partial = true;
</div>
<button type="submit">Login</button>
</form>
[[ end ]]

View File

@ -1,19 +0,0 @@
---
import Layout from '../layouts/Layout.astro';
---
<Layout>
<div>Log in as: <span>[[ .LoginName ]]</span></div>
<div>
<form method="POST" action="/login">
<button type="submit" name="not-you" value="1">Not You?</button>
</form>
</div>
<div>
<form method="POST" action="/login">
<input type="hidden" name="redirect" value="[[ .Redirect ]]"/>
<input type="hidden" name="loginname" value="[[ .LoginName ]]"/>
<button type="submit">Continue</button>
</form>
</div>
</Layout>

View File

@ -9,16 +9,6 @@ import Layout from "../layouts/Layout.astro";
<p>Check your inbox for a verification email</p>
[[ end ]]
[[ .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>
</div>
<button type="submit">Continue</button>
</form>
</div>
[[ if gt (len .AuthButtons) 0 ]]
<div class="auth-buttons">
[[ range $authButton := .AuthButtons ]]