lavender/server/login.go

423 lines
13 KiB
Go
Raw Permalink Normal View History

2024-02-07 01:18:17 +00:00
package server
import (
2024-02-07 10:54:37 +00:00
"context"
"database/sql"
2024-02-07 10:54:37 +00:00
"encoding/json"
"errors"
"fmt"
2024-09-13 15:31:40 +01:00
auth2 "github.com/1f349/lavender/auth"
"github.com/1f349/lavender/database"
"github.com/1f349/lavender/database/types"
"github.com/1f349/lavender/issuer"
2024-02-07 01:18:17 +00:00
"github.com/1f349/lavender/pages"
"github.com/1f349/mjwt"
2024-02-15 15:23:10 +00:00
"github.com/1f349/mjwt/auth"
"github.com/golang-jwt/jwt/v4"
2024-02-07 01:18:17 +00:00
"github.com/google/uuid"
"github.com/julienschmidt/httprouter"
"github.com/mrmelon54/pronouns"
2024-02-07 01:18:17 +00:00
"golang.org/x/oauth2"
"golang.org/x/text/language"
2024-02-07 01:18:17 +00:00
"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")
}
2024-09-13 15:31:40 +01:00
func (h *httpServer) loginGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
2024-02-07 10:54:37 +00:00
if !auth.IsGuest() {
h.SafeRedirect(rw, req)
return
}
2024-02-07 01:18:17 +00:00
cookie, err := req.Cookie("lavender-login-name")
if err == nil && cookie.Valid() == nil {
pages.RenderPageTemplate(rw, "login-memory", map[string]any{
"ServiceName": h.conf.ServiceName,
"LoginName": cookie.Value,
"Redirect": req.URL.Query().Get("redirect"),
2024-02-07 01:18:17 +00:00
})
return
}
pages.RenderPageTemplate(rw, "login", map[string]any{
"ServiceName": h.conf.ServiceName,
"Redirect": req.URL.Query().Get("redirect"),
2024-02-07 01:18:17 +00:00
})
}
2024-09-13 15:31:40 +01:00
func (h *httpServer) loginPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
2024-02-07 10:54:37 +00:00
if !auth.IsGuest() {
h.SafeRedirect(rw, req)
return
}
2024-02-07 01:18:17 +00:00
if req.PostFormValue("not-you") == "1" {
http.SetCookie(rw, &http.Cookie{
Name: "lavender-login-name",
Value: "",
Path: "/",
MaxAge: -1,
Secure: true,
2024-02-15 14:44:58 +00:00
SameSite: http.SameSiteLaxMode,
2024-02-07 01:18:17 +00:00
})
http.Redirect(rw, req, (&url.URL{
Path: "/login",
}).String(), http.StatusFound)
return
}
loginName := req.PostFormValue("loginname")
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
n := strings.IndexByte(loginName, '@')
loginUn := loginName[:n]
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,
2024-02-15 14:44:58 +00:00
SameSite: http.SameSiteLaxMode,
2024-02-07 01:18:17 +00:00
})
// save state for use later
state := login.Config.Namespace + ":" + uuid.NewString()
2024-05-31 13:51:44 +01:00
h.flowState.Set(state, flowStateData{loginName, login, req.PostFormValue("redirect")}, time.Now().Add(15*time.Minute))
2024-02-07 01:18:17 +00:00
// generate oauth2 config and redirect to authorize URL
oa2conf := login.OAuth2Config
oa2conf.RedirectURL = h.conf.BaseUrl + "/callback"
nextUrl := oa2conf.AuthCodeURL(state, oauth2.SetAuthURLParam("login_name", loginUn))
http.Redirect(rw, req, nextUrl, http.StatusFound)
}
2024-02-07 10:54:37 +00:00
2024-09-13 15:31:40 +01:00
func (h *httpServer) loginCallback(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, userAuth UserAuth) {
2024-02-07 10:54:37 +00:00
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+"/callback"))
if err != nil {
http.Error(rw, "Failed to exchange code for token", http.StatusInternalServerError)
return
}
2024-05-31 13:51:44 +01:00
userAuth, err = h.updateExternalUserInfo(req, flowState.sso, token)
if err != nil {
http.Error(rw, "Failed to update external user info", http.StatusInternalServerError)
2024-02-07 10:54:37 +00:00
return
}
2024-05-31 13:51:44 +01:00
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)
}
2024-09-13 15:31:40 +01:00
func (h *httpServer) updateExternalUserInfo(req *http.Request, sso *issuer.WellKnownOIDC, token *oauth2.Token) (UserAuth, error) {
2024-05-31 13:51:44 +01:00
sessionData, err := h.fetchUserInfo(sso, token)
if err != nil || sessionData.Subject == "" {
return UserAuth{}, fmt.Errorf("failed to fetch user info")
}
2024-10-06 15:50:23 +01:00
// TODO(melon): fix this to use a merging of lavender and tulip auth
2024-10-06 15:50:23 +01:00
// 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 UserAuth{
Subject: userSubject,
NeedOtp: sessionData.NeedOtp,
UserInfo: sessionData.UserInfo,
}, err
case errors.Is(err, sql.ErrNoRows):
// happy path for registration
break
default:
// another error occurred
return UserAuth{}, err
}
2024-10-06 15:50:23 +01:00
// guard for disabled registration
if !sso.Config.Registration {
return 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,
2024-10-06 15:50:23 +01:00
Name: name,
Username: sessionData.UserInfo.GetStringFromKeysOrEmpty("login", "preferred_username"),
AuthNamespace: sso.Namespace,
AuthUser: sessionData.UserInfo.GetStringOrEmpty("sub"),
})
if err != nil {
return err
}
2024-10-06 15:50:23 +01:00
// if adding the user succeeds then update the profile
return h.updateOAuth2UserProfile(req.Context(), tx, sessionData)
})
if err != nil {
return 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 UserAuth{}, err
}
// TODO(melon): this feels bad
sessionData = UserAuth{
Subject: userSubject,
NeedOtp: sessionData.NeedOtp,
UserInfo: sessionData.UserInfo,
}
return sessionData, nil
}
func (h *httpServer) updateOAuth2UserProfile(ctx context.Context, tx *database.Queries, sessionData 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
}
2024-10-06 15:50:23 +01:00
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,
})
2024-05-31 13:51:44 +01:00
})
2024-02-07 10:54:37 +00:00
}
2024-05-31 13:51:44 +01:00
const twelveHours = 12 * time.Hour
const oneWeek = 7 * 24 * time.Hour
2024-02-15 15:23:10 +00:00
2024-05-31 13:51:44 +01:00
type lavenderLoginAccess struct {
2024-09-13 15:31:40 +01:00
UserInfo auth2.UserInfoFields `json:"user_info"`
auth.AccessTokenClaims
}
2024-05-31 13:51:44 +01:00
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() }
2024-05-31 13:51:44 +01:00
func (l lavenderLoginRefresh) Type() string { return "lavender-login-refresh" }
func (h *httpServer) setLoginDataCookie2(rw http.ResponseWriter, authData UserAuth) bool {
2024-10-06 15:50:23 +01:00
// TODO(melon): should probably merge these methods
return h.setLoginDataCookie(rw, authData, "")
}
2024-09-13 15:31:40 +01:00
func (h *httpServer) setLoginDataCookie(rw http.ResponseWriter, authData UserAuth, loginName string) bool {
ps := auth.NewPermStorage()
2024-05-31 13:51:44 +01:00
accId := uuid.NewString()
gen, err := h.signingKey.GenerateJwt(authData.Subject, accId, jwt.ClaimStrings{h.conf.BaseUrl}, twelveHours, lavenderLoginAccess{
UserInfo: authData.UserInfo,
AccessTokenClaims: auth.AccessTokenClaims{Perms: ps},
})
if err != nil {
2024-02-15 15:23:10 +00:00
http.Error(rw, "Failed to generate cookie token", http.StatusInternalServerError)
return true
}
2024-05-31 13:51:44 +01:00
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
}
2024-02-07 10:54:37 +00:00
http.SetCookie(rw, &http.Cookie{
2024-05-31 13:51:44 +01:00
Name: "lavender-login-access",
2024-02-15 15:23:10 +00:00
Value: gen,
2024-02-07 10:54:37 +00:00
Path: "/",
Secure: true,
2024-05-31 13:51:44 +01:00
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,
2024-02-15 14:44:58 +00:00
SameSite: http.SameSiteLaxMode,
2024-02-07 10:54:37 +00:00
})
return false
}
func readJwtCookie[T mjwt.Claims](req *http.Request, cookieName string, signingKey *mjwt.KeyStore) (mjwt.BaseTypeClaims[T], error) {
2024-05-31 13:51:44 +01:00
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
}
2024-09-13 15:31:40 +01:00
func (h *httpServer) readLoginAccessCookie(rw http.ResponseWriter, req *http.Request, u *UserAuth) error {
loginData, err := readJwtCookie[lavenderLoginAccess](req, "lavender-login-access", h.signingKey.KeyStore())
2024-05-31 13:51:44 +01:00
if err != nil {
return h.readLoginRefreshCookie(rw, req, u)
}
*u = UserAuth{
Subject: loginData.Subject,
UserInfo: loginData.Claims.UserInfo,
}
return nil
}
2024-09-13 15:31:40 +01:00
func (h *httpServer) readLoginRefreshCookie(rw http.ResponseWriter, req *http.Request, userAuth *UserAuth) error {
refreshData, err := readJwtCookie[lavenderLoginRefresh](req, "lavender-login-refresh", h.signingKey.KeyStore())
if err != nil {
return err
}
2024-05-31 13:51:44 +01:00
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 {
2024-05-31 13:51:44 +01:00
return fmt.Errorf("invalid oauth token")
}
oauthToken = &oauth2.Token{
AccessToken: token.AccessToken.String,
RefreshToken: token.RefreshToken.String,
Expiry: token.TokenExpiry.Time,
2024-05-31 13:51:44 +01:00
}
return nil
})
*userAuth, err = h.updateExternalUserInfo(req, sso, oauthToken)
if err != nil {
return err
}
2024-05-31 13:51:44 +01:00
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
}
2024-09-13 15:31:40 +01:00
func (h *httpServer) fetchUserInfo(sso *issuer.WellKnownOIDC, token *oauth2.Token) (UserAuth, error) {
res, err := sso.OAuth2Config.Client(context.Background(), token).Get(sso.UserInfoEndpoint)
if err != nil || res.StatusCode != http.StatusOK {
2024-02-15 15:09:14 +00:00
return UserAuth{}, fmt.Errorf("request failed")
}
defer res.Body.Close()
2024-09-13 15:31:40 +01:00
var userInfoJson auth2.UserInfoFields
if err := json.NewDecoder(res.Body).Decode(&userInfoJson); err != nil {
2024-02-15 15:09:14 +00:00
return UserAuth{}, err
}
subject, ok := userInfoJson.GetString("sub")
if !ok {
2024-02-15 15:09:14 +00:00
return UserAuth{}, fmt.Errorf("invalid subject")
}
2024-10-06 15:50:23 +01:00
// TODO(melon): there is no need for this
//subject += "@" + sso.Config.Namespace
2024-02-15 15:09:14 +00:00
return UserAuth{
Subject: subject,
UserInfo: userInfoJson,
}, nil
}