lavender/server/server.go

202 lines
5.6 KiB
Go

package server
import (
"encoding/json"
"errors"
"fmt"
"github.com/1f349/cache"
"github.com/1f349/lavender/auth"
"github.com/1f349/lavender/auth/process"
"github.com/1f349/lavender/auth/providers"
"github.com/1f349/lavender/conf"
"github.com/1f349/lavender/database"
"github.com/1f349/lavender/issuer"
"github.com/1f349/lavender/logger"
"github.com/1f349/lavender/mail"
"github.com/1f349/lavender/utils"
"github.com/1f349/lavender/web"
"github.com/1f349/mjwt"
"github.com/go-oauth2/oauth2/v4/manage"
"github.com/go-oauth2/oauth2/v4/server"
"github.com/julienschmidt/httprouter"
"golang.org/x/oauth2"
"net/http"
"path"
"strings"
)
var errInvalidScope = errors.New("missing required scope")
type httpServer struct {
r *httprouter.Router
oauthSrv *server.Server
oauthMgr *manage.Manager
db *database.Queries
conf conf.Conf
signingKey *mjwt.Issuer
mailSender *mail.Mail
manager *issuer.Manager
// 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
formProviderLookup map[string]auth.Form
}
type flowStateData struct {
loginName string
sso *issuer.WellKnownOIDC
redirect string
}
type mailLink byte
const (
mailLinkDelete mailLink = iota
mailLinkResetPassword
mailLinkVerifyEmail
)
type mailLinkKey struct {
action mailLink
data string
}
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
// TODO: move oauth setup into oauth provider
hs := &httpServer{
r: r,
db: db,
conf: config,
signingKey: signingKey,
mailSender: mailSender,
mailLinkCache: cache.New[mailLinkKey, string](),
}
var err error
hs.manager, err = issuer.NewManager(db, config.Namespace)
if err != nil {
logger.Logger.Fatal("Failed to load SSO services", "err", err)
}
authPassword := &providers.PasswordLogin{DB: db}
authOtp := &providers.OtpLogin{DB: db}
authOAuth := &providers.OAuthLogin{DB: db, BaseUrl: config.BaseUrl, Manager: hs.manager}
authOAuth.Init()
authPasskey := &providers.PasskeyLogin{DB: db}
authInitial := &providers.InitialLogin{DB: db, MyNamespace: config.Namespace, Manager: hs.manager, OAuth: authOAuth}
hs.authSources = []auth.Provider{
authInitial,
authPassword,
authOtp,
authOAuth,
authPasskey,
}
r.GET("/oauth/:namespace/start", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
namespace := params.ByName("namespace")
if !authOAuth.RedirectToAuthorize(rw, req, namespace) {
http.Error(rw, "Invalid OAuth namespace", http.StatusBadRequest)
}
})
r.GET("/oauth/:namespace/callback", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
namespace := params.ByName("namespace")
authOAuth.OAuthCallback(rw, req, namespace, func(req *http.Request, sso *issuer.WellKnownOIDC, token *oauth2.Token) (auth.UserAuth, error) {
resp, err := sso.OAuth2Config.Client(req.Context(), token).Get(sso.UserInfoEndpoint.String())
if err != nil {
return auth.UserAuth{}, err
}
defer resp.Body.Close()
var userInfoJson auth.UserInfoFields
if err := json.NewDecoder(resp.Body).Decode(&userInfoJson); err != nil {
return auth.UserAuth{}, err
}
subject, ok := userInfoJson.GetString("sub")
if !ok {
return auth.UserAuth{}, fmt.Errorf("invalid subject")
}
subject += "@" + sso.Config.Namespace
return auth.UserAuth{
Subject: subject,
Factor: process.StateBasic,
UserInfo: userInfoJson,
}, nil
}, func(rw http.ResponseWriter, authData auth.UserAuth, loginName string) bool {
// TODO: this should be using the existing auth flow calls
return hs.setLoginDataCookie(rw, authData, loginName)
}, func(rw http.ResponseWriter, req *http.Request) {
// TODO: is this really needed like this?
utils.SafeRedirect(rw, req)
})
})
// build slices and maps for quick access to auth interfaces
hs.authButtons = make([]auth.Button, 0)
hs.formProviderLookup = make(map[string]auth.Form)
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
}
}
SetupOpenId(r, config.BaseUrl, signingKey)
r.GET("/", hs.OptionalAuthentication(false, hs.Home))
r.POST("/logout", hs.RequireAuthentication(hs.logoutPost))
// theme styles
r.GET("/_astro/*filepath", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
name := params.ByName("filepath")
if strings.Contains(name, "..") {
http.Error(rw, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
web.RenderWebAsset(rw, req, path.Join("_astro", name))
})
// login steps
r.GET("/login", hs.OptionalAuthentication(false, hs.loginGet))
r.POST("/login", hs.OptionalAuthentication(false, hs.loginPost))
r.GET("/callback", hs.OptionalAuthentication(false, hs.loginCallback))
SetupManageApps(r, hs)
SetupManageUsers(r, hs)
SetupOAuth2(r, hs, signingKey, db)
}
func ParseClaims(claims string) map[string]bool {
m := make(map[string]bool)
for {
n := strings.IndexByte(claims, ' ')
if n == -1 {
if claims != "" {
m[claims] = true
}
break
}
a := claims[:n]
claims = claims[n+1:]
if a != "" {
m[a] = true
}
}
return m
}