mirror of
https://github.com/1f349/lavender.git
synced 2025-04-14 06:55:55 +01:00
421 lines
12 KiB
Go
421 lines
12 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"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/1f349/lavender/issuer"
|
|
"github.com/1f349/lavender/logger"
|
|
"github.com/1f349/lavender/utils"
|
|
"github.com/1f349/lavender/web"
|
|
"github.com/1f349/mjwt"
|
|
mjwtAuth "github.com/1f349/mjwt/auth"
|
|
"github.com/golang-jwt/jwt/v4"
|
|
"github.com/google/uuid"
|
|
"github.com/julienschmidt/httprouter"
|
|
"golang.org/x/oauth2"
|
|
"html/template"
|
|
"net/http"
|
|
"net/url"
|
|
"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) getAuthWithState(state process.State) auth.Provider {
|
|
for _, i := range h.authSources {
|
|
if i.AccessState() == state {
|
|
return i
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *httpServer) renderAuthTemplate(req *http.Request, provider auth.Form, processData process.LoginProcessData, user *database.User) (template.HTML, error) {
|
|
tmpCtx := authContext.NewTemplateContext(req, user, processData)
|
|
|
|
err := provider.RenderTemplate(tmpCtx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
w := new(bytes.Buffer)
|
|
if web.RenderPageTemplate(w, "auth/"+provider.Name(), tmpCtx.Data()) {
|
|
return template.HTML(w.Bytes()), nil
|
|
}
|
|
return "", fmt.Errorf("failed to render auth template")
|
|
}
|
|
|
|
func (h *httpServer) loginGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, userAuth auth.UserAuth) {
|
|
if !userAuth.IsGuest() {
|
|
utils.SafeRedirect(rw, req)
|
|
return
|
|
}
|
|
|
|
var processData process.LoginProcessData
|
|
var user *database.User
|
|
|
|
jwtCookie, err := readJwtCookie[process.LoginProcessData](req, "lavender-login-process", h.signingKey.KeyStore())
|
|
if err == nil {
|
|
processData = jwtCookie.Claims
|
|
user = h.resolveUser(req.Context(), processData)
|
|
}
|
|
|
|
// TODO: some of this should be more like tulip
|
|
fmt.Println("Starting login process with data", "process", processData)
|
|
|
|
buttonCtx := authContext.NewTemplateContext(req, new(database.User), processData)
|
|
|
|
buttonTemplates := make([]template.HTML, 0, len(h.authButtons))
|
|
for i := range h.authButtons {
|
|
h.authButtons[i].RenderButtonTemplate(buttonCtx)
|
|
if buttonCtx.Data() != nil {
|
|
// TODO: finish the buttons here
|
|
buf := new(bytes.Buffer)
|
|
web.RenderPageTemplate(buf, "auth-buttons/"+h.authButtons[i].Name(), buttonCtx.Data())
|
|
buttonTemplates = append(buttonTemplates, template.HTML(buf.String()))
|
|
buttonCtx.Render(nil)
|
|
}
|
|
}
|
|
|
|
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 {
|
|
logger.Logger.Warn("Provider does not support forms", "state", processData.State, "provider", provider)
|
|
web.RenderPageTemplate(rw, "login-error", struct {
|
|
ServiceName string `json:"service_name"`
|
|
Error string `json:"error"`
|
|
}{
|
|
ServiceName: h.conf.ServiceName,
|
|
Error: "No available provider for login",
|
|
})
|
|
return
|
|
}
|
|
|
|
renderTemplate, err = h.renderAuthTemplate(req, form, processData, user)
|
|
if err != nil {
|
|
logger.Logger.Warn("renderAuthTemplate()", "state", processData.State, "provider", provider.Name(), "err", err)
|
|
web.RenderPageTemplate(rw, "login-error", struct {
|
|
ServiceName string `json:"service_name"`
|
|
Error string `json:"error"`
|
|
}{
|
|
ServiceName: h.conf.ServiceName,
|
|
Error: "No available provider for login",
|
|
})
|
|
return
|
|
}
|
|
|
|
// render different page sources
|
|
web.RenderPageTemplate(rw, "login", map[string]any{
|
|
"ServiceName": h.conf.ServiceName,
|
|
"LoginName": "",
|
|
"Redirect": req.URL.Query().Get("redirect"),
|
|
"Source": "start",
|
|
"AuthTemplate": renderTemplate,
|
|
"AuthButtons": buttonTemplates,
|
|
})
|
|
}
|
|
|
|
func (h *httpServer) loginPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth2 auth.UserAuth) {
|
|
if !auth2.IsGuest() {
|
|
utils.SafeRedirect(rw, req)
|
|
return
|
|
}
|
|
|
|
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)
|
|
|
|
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()
|
|
|
|
// if the state is basic and the user has no OTP secret or OTP digits then skip OTP
|
|
if processData.State == process.StateBasic {
|
|
var user *database.User
|
|
if processData.Subject != "" {
|
|
userRaw, err := h.db.GetUser(req.Context(), processData.Subject)
|
|
if err == nil {
|
|
user = &userRaw
|
|
}
|
|
}
|
|
if user != nil && user.OtpSecret == "" && user.OtpDigits == 0 {
|
|
processData.State = process.StateAuthenticated
|
|
}
|
|
}
|
|
|
|
switch processData.State {
|
|
case process.StateAuthenticated:
|
|
// set the access and refresh tokens
|
|
if h.setLoginDataCookie(rw, auth.UserAuth{
|
|
Subject: processData.Subject,
|
|
Factor: processData.State,
|
|
UserInfo: auth.UserInfoFields{},
|
|
}, processData.Email) {
|
|
return
|
|
}
|
|
|
|
case process.StateSudo:
|
|
// sudo is not implemented yet
|
|
logger.Logger.Error("Hit StateSudo")
|
|
http.Error(rw, "This should not be possible yet", http.StatusNotImplemented)
|
|
return
|
|
|
|
default:
|
|
// update the process state
|
|
if h.setLoginProcessCookie(rw, processData) {
|
|
return
|
|
}
|
|
}
|
|
|
|
// TODO: figure this out (not sure what?)
|
|
logger.Logger.Debug("POST /login: form render data", "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) {
|
|
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
|
|
const oneWeek = 7 * 24 * time.Hour
|
|
|
|
type lavenderLoginAccess struct {
|
|
UserInfo auth.UserInfoFields `json:"user_info"`
|
|
Factor process.State `json:"factor"`
|
|
mjwtAuth.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"`
|
|
mjwtAuth.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 auth.UserAuth, loginName string) bool {
|
|
ps := mjwtAuth.NewPermStorage()
|
|
accId := uuid.NewString()
|
|
gen, err := h.signingKey.GenerateJwt(authData.Subject, accId, jwt.ClaimStrings{h.conf.BaseUrl.String()}, twelveHours, lavenderLoginAccess{
|
|
UserInfo: authData.UserInfo,
|
|
Factor: authData.Factor,
|
|
AccessTokenClaims: mjwtAuth.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.String()}, oneWeek, lavenderLoginRefresh{
|
|
Login: loginName,
|
|
RefreshTokenClaims: mjwtAuth.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 *auth.UserAuth) error {
|
|
loginData, err := readJwtCookie[lavenderLoginAccess](req, "lavender-login-access", h.signingKey.KeyStore())
|
|
if err != nil {
|
|
return h.readLoginRefreshCookie(rw, req, u)
|
|
}
|
|
*u = auth.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 *auth.UserAuth) error {
|
|
refreshData, err := readJwtCookie[lavenderLoginRefresh](req, "lavender-login-refresh", h.signingKey.KeyStore())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, namespace, err := utils.ParseLoginName(refreshData.Claims.Login)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
sso := h.manager.GetService(namespace)
|
|
if sso == nil {
|
|
return fmt.Errorf("invalid namespace: %s", namespace)
|
|
}
|
|
|
|
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
|
|
})
|
|
|
|
// TODO: not sure how I want to handle this yet...
|
|
*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) resolveUser(ctx context.Context, data process.LoginProcessData) *database.User {
|
|
// resolve database.User struct
|
|
if data.Subject != "" {
|
|
userRaw, err := h.db.GetUser(ctx, data.Subject)
|
|
if err == nil {
|
|
return &userRaw
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TODO: not sure how I want to handle this yet...
|
|
func (h *httpServer) updateExternalUserInfo(req *http.Request, sso *issuer.WellKnownOIDC, token *oauth2.Token) (auth.UserAuth, error) {
|
|
return auth.UserAuth{}, fmt.Errorf("no")
|
|
}
|