mirror of
https://github.com/1f349/lavender.git
synced 2025-04-14 06:55:55 +01:00
459 lines
14 KiB
Go
459 lines
14 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
auth2 "github.com/1f349/lavender/auth"
|
|
"github.com/1f349/lavender/database"
|
|
"github.com/1f349/lavender/database/types"
|
|
"github.com/1f349/lavender/issuer"
|
|
"github.com/1f349/lavender/pages"
|
|
"github.com/1f349/mjwt"
|
|
"github.com/1f349/mjwt/auth"
|
|
"github.com/golang-jwt/jwt/v4"
|
|
"github.com/google/uuid"
|
|
"github.com/julienschmidt/httprouter"
|
|
"github.com/mrmelon54/pronouns"
|
|
"golang.org/x/oauth2"
|
|
"golang.org/x/text/language"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// getUserLoginName finds the `login_name` query parameter within the `/authorize` redirect url
|
|
func getUserLoginName(req *http.Request) string {
|
|
q := req.URL.Query()
|
|
if !q.Has("redirect") {
|
|
return ""
|
|
}
|
|
originUrl, err := url.ParseRequestURI(q.Get("redirect"))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
if originUrl.Path != "/authorize" {
|
|
return ""
|
|
}
|
|
return originUrl.Query().Get("login_name")
|
|
}
|
|
|
|
func (h *httpServer) testAuthSources(req *http.Request, user *database.User, factor auth2.Factor) map[string]bool {
|
|
authSource := make(map[string]bool)
|
|
data := make(map[string]any)
|
|
for _, i := range h.authSources {
|
|
// ignore not-supported factors
|
|
if i.Factor()&factor == 0 {
|
|
continue
|
|
}
|
|
err := i.RenderData(req.Context(), req, user, data)
|
|
authSource[i.Name()] = err == nil
|
|
clear(data)
|
|
}
|
|
return authSource
|
|
}
|
|
|
|
func (h *httpServer) loginGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth auth2.UserAuth) {
|
|
if !auth.IsGuest() {
|
|
h.SafeRedirect(rw, req)
|
|
return
|
|
}
|
|
|
|
cookie, err := req.Cookie("lavender-login-name")
|
|
if err == nil && cookie.Valid() == nil {
|
|
user, err := h.db.GetUser(req.Context(), auth.Subject)
|
|
var userPtr *database.User
|
|
switch {
|
|
case err == nil:
|
|
userPtr = &user
|
|
case errors.Is(err, sql.ErrNoRows):
|
|
userPtr = nil
|
|
default:
|
|
http.Error(rw, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
fmt.Printf("%#v\n", h.testAuthSources(req, userPtr, auth2.FactorFirst))
|
|
|
|
pages.RenderPageTemplate(rw, "login-memory", map[string]any{
|
|
"ServiceName": h.conf.ServiceName,
|
|
"LoginName": cookie.Value,
|
|
"Redirect": req.URL.Query().Get("redirect"),
|
|
"Source": "start",
|
|
"Auth": h.testAuthSources(req, userPtr, auth2.FactorFirst),
|
|
})
|
|
return
|
|
}
|
|
|
|
// render different page sources
|
|
pages.RenderPageTemplate(rw, "login", map[string]any{
|
|
"ServiceName": h.conf.ServiceName,
|
|
"LoginName": "",
|
|
"Redirect": req.URL.Query().Get("redirect"),
|
|
"Source": "start",
|
|
"Auth": h.testAuthSources(req, nil, auth2.FactorFirst),
|
|
})
|
|
}
|
|
|
|
func (h *httpServer) loginPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth auth2.UserAuth) {
|
|
if !auth.IsGuest() {
|
|
h.SafeRedirect(rw, req)
|
|
return
|
|
}
|
|
|
|
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("loginname")
|
|
|
|
// 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 := auth2.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 auth2.RedirectError
|
|
|
|
// if the login is the local server
|
|
if login == issuer.MeWellKnown {
|
|
// TODO(melon): work on this
|
|
err := h.authBasic.AttemptLogin(ctx, req, nil)
|
|
switch {
|
|
case errors.As(err, &redirectError):
|
|
http.Redirect(rw, req, redirectError.Target, redirectError.Code)
|
|
return
|
|
}
|
|
return
|
|
}
|
|
|
|
err := h.authOAuth.AttemptLogin(ctx, req, nil)
|
|
switch {
|
|
case errors.As(err, &redirectError):
|
|
http.Redirect(rw, req, redirectError.Target, redirectError.Code)
|
|
return
|
|
}
|
|
}
|
|
|
|
func (h *httpServer) loginCallback(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, userAuth auth2.UserAuth) {
|
|
h.authOAuth.OAuthCallback(rw, req, h.updateExternalUserInfo, h.setLoginDataCookie, h.SafeRedirect)
|
|
}
|
|
|
|
func (h *httpServer) updateExternalUserInfo(req *http.Request, sso *issuer.WellKnownOIDC, token *oauth2.Token) (auth2.UserAuth, error) {
|
|
sessionData, err := h.fetchUserInfo(sso, token)
|
|
if err != nil || sessionData.Subject == "" {
|
|
return auth2.UserAuth{}, fmt.Errorf("failed to fetch user info")
|
|
}
|
|
|
|
// TODO(melon): fix this to use a merging of lavender and tulip auth
|
|
|
|
// find an existing user with the matching oauth2 namespace and subject
|
|
var userSubject string
|
|
err = h.DbTxError(func(tx *database.Queries) (err error) {
|
|
userSubject, err = tx.FindUserByAuth(req.Context(), database.FindUserByAuthParams{
|
|
AuthType: types.AuthTypeOauth2,
|
|
AuthNamespace: sso.Namespace,
|
|
AuthUser: sessionData.Subject,
|
|
})
|
|
return
|
|
})
|
|
switch {
|
|
case err == nil:
|
|
// user already exists
|
|
err = h.DbTxError(func(tx *database.Queries) error {
|
|
return h.updateOAuth2UserProfile(req.Context(), tx, sessionData)
|
|
})
|
|
return auth2.UserAuth{
|
|
Subject: userSubject,
|
|
Factor: auth2.FactorAuthorized,
|
|
UserInfo: sessionData.UserInfo,
|
|
}, err
|
|
case errors.Is(err, sql.ErrNoRows):
|
|
// happy path for registration
|
|
break
|
|
default:
|
|
// another error occurred
|
|
return auth2.UserAuth{}, err
|
|
}
|
|
|
|
// guard for disabled registration
|
|
if !sso.Config.Registration {
|
|
return auth2.UserAuth{}, fmt.Errorf("registration is not enabled for this authentication source")
|
|
}
|
|
|
|
// TODO(melon): rework this
|
|
name := sessionData.UserInfo.GetStringOrDefault("name", "Unknown User")
|
|
uEmail := sessionData.UserInfo.GetStringOrDefault("email", "unknown@localhost")
|
|
uEmailVerified, _ := sessionData.UserInfo.GetBoolean("email_verified")
|
|
|
|
err = h.DbTxError(func(tx *database.Queries) (err error) {
|
|
userSubject, err = tx.AddOAuthUser(req.Context(), database.AddOAuthUserParams{
|
|
Email: uEmail,
|
|
EmailVerified: uEmailVerified,
|
|
Name: name,
|
|
Username: sessionData.UserInfo.GetStringFromKeysOrEmpty("login", "preferred_username"),
|
|
AuthNamespace: sso.Namespace,
|
|
AuthUser: sessionData.UserInfo.GetStringOrEmpty("sub"),
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// if adding the user succeeds then update the profile
|
|
return h.updateOAuth2UserProfile(req.Context(), tx, sessionData)
|
|
})
|
|
if err != nil {
|
|
return auth2.UserAuth{}, err
|
|
}
|
|
|
|
// only continues if the above tx succeeds
|
|
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},
|
|
TokenExpiry: sql.NullTime{Time: token.Expiry, Valid: true},
|
|
Subject: sessionData.Subject,
|
|
})
|
|
}); err != nil {
|
|
return auth2.UserAuth{}, err
|
|
}
|
|
|
|
// TODO(melon): this feels bad
|
|
sessionData = auth2.UserAuth{
|
|
Subject: userSubject,
|
|
Factor: auth2.FactorAuthorized,
|
|
UserInfo: sessionData.UserInfo,
|
|
}
|
|
|
|
return sessionData, nil
|
|
}
|
|
|
|
func (h *httpServer) updateOAuth2UserProfile(ctx context.Context, tx *database.Queries, sessionData auth2.UserAuth) error {
|
|
// all of these updates must succeed
|
|
return tx.UseTx(ctx, func(tx *database.Queries) error {
|
|
name := sessionData.UserInfo.GetStringOrDefault("name", "Unknown User")
|
|
|
|
err := tx.ModifyUserRemoteLogin(ctx, database.ModifyUserRemoteLoginParams{
|
|
Login: sessionData.UserInfo.GetStringFromKeysOrEmpty("login", "preferred_username"),
|
|
ProfileUrl: sessionData.UserInfo.GetStringOrEmpty("profile"),
|
|
Subject: sessionData.Subject,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
pronoun, err := pronouns.FindPronoun(sessionData.UserInfo.GetStringOrEmpty("pronouns"))
|
|
if err != nil {
|
|
pronoun = pronouns.TheyThem
|
|
}
|
|
locale, err := language.Parse(sessionData.UserInfo.GetStringOrEmpty("locale"))
|
|
if err != nil {
|
|
locale = language.AmericanEnglish
|
|
}
|
|
|
|
return tx.ModifyProfile(ctx, database.ModifyProfileParams{
|
|
Name: name,
|
|
Picture: sessionData.UserInfo.GetStringOrEmpty("profile"),
|
|
Website: sessionData.UserInfo.GetStringOrEmpty("website"),
|
|
Pronouns: types.UserPronoun{Pronoun: pronoun},
|
|
Birthdate: sessionData.UserInfo.GetNullDate("birthdate"),
|
|
Zone: sessionData.UserInfo.GetStringOrDefault("zoneinfo", "UTC"),
|
|
Locale: types.UserLocale{Tag: locale},
|
|
UpdatedAt: time.Now(),
|
|
Subject: sessionData.Subject,
|
|
})
|
|
})
|
|
}
|
|
|
|
const twelveHours = 12 * time.Hour
|
|
const oneWeek = 7 * 24 * time.Hour
|
|
|
|
type lavenderLoginAccess struct {
|
|
UserInfo auth2.UserInfoFields `json:"user_info"`
|
|
Factor auth2.Factor `json:"factor"`
|
|
auth.AccessTokenClaims
|
|
}
|
|
|
|
func (l lavenderLoginAccess) Valid() error { return l.AccessTokenClaims.Valid() }
|
|
|
|
func (l lavenderLoginAccess) Type() string { return "lavender-login-access" }
|
|
|
|
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 auth2.UserAuth, loginName string) bool {
|
|
ps := auth.NewPermStorage()
|
|
accId := uuid.NewString()
|
|
gen, err := h.signingKey.GenerateJwt(authData.Subject, accId, jwt.ClaimStrings{h.conf.BaseUrl}, twelveHours, lavenderLoginAccess{
|
|
UserInfo: authData.UserInfo,
|
|
Factor: authData.Factor,
|
|
AccessTokenClaims: 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}, 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-access",
|
|
Value: gen,
|
|
Path: "/",
|
|
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 readJwtCookie[T mjwt.Claims](req *http.Request, cookieName string, signingKey *mjwt.KeyStore) (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 *auth2.UserAuth) error {
|
|
loginData, err := readJwtCookie[lavenderLoginAccess](req, "lavender-login-access", h.signingKey.KeyStore())
|
|
if err != nil {
|
|
return h.readLoginRefreshCookie(rw, req, u)
|
|
}
|
|
*u = auth2.UserAuth{
|
|
Subject: loginData.Subject,
|
|
Factor: loginData.Claims.Factor,
|
|
UserInfo: loginData.Claims.UserInfo,
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *httpServer) readLoginRefreshCookie(rw http.ResponseWriter, req *http.Request, userAuth *auth2.UserAuth) error {
|
|
refreshData, err := readJwtCookie[lavenderLoginRefresh](req, "lavender-login-refresh", h.signingKey.KeyStore())
|
|
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.TokenExpiry.Valid {
|
|
return fmt.Errorf("invalid oauth token")
|
|
}
|
|
oauthToken = &oauth2.Token{
|
|
AccessToken: token.AccessToken.String,
|
|
RefreshToken: token.RefreshToken.String,
|
|
Expiry: token.TokenExpiry.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
|
|
}
|
|
|
|
func (h *httpServer) fetchUserInfo(sso *issuer.WellKnownOIDC, token *oauth2.Token) (auth2.UserAuth, error) {
|
|
res, err := sso.OAuth2Config.Client(context.Background(), token).Get(sso.UserInfoEndpoint)
|
|
if err != nil || res.StatusCode != http.StatusOK {
|
|
return auth2.UserAuth{}, fmt.Errorf("request failed")
|
|
}
|
|
defer res.Body.Close()
|
|
|
|
var userInfoJson auth2.UserInfoFields
|
|
if err := json.NewDecoder(res.Body).Decode(&userInfoJson); err != nil {
|
|
return auth2.UserAuth{}, err
|
|
}
|
|
subject, ok := userInfoJson.GetString("sub")
|
|
if !ok {
|
|
return auth2.UserAuth{}, fmt.Errorf("invalid subject")
|
|
}
|
|
|
|
// TODO(melon): there is no need for this
|
|
//subject += "@" + sso.Config.Namespace
|
|
|
|
return auth2.UserAuth{
|
|
Subject: subject,
|
|
Factor: auth2.FactorAuthorized,
|
|
UserInfo: userInfoJson,
|
|
}, nil
|
|
}
|