This commit is contained in:
Melon 2025-01-19 12:04:25 +00:00
parent 15540ef16b
commit 0df2cf6681
Signed by: melon
GPG Key ID: 6C9D970C50D26A25
28 changed files with 502 additions and 173 deletions

15
auth/auth-buttons.go Normal file
View File

@ -0,0 +1,15 @@
package auth
import (
"context"
"html/template"
"net/http"
)
type Button interface {
// ButtonName defines the text to show on the button.
ButtonName() string
// RenderButtonTemplate returns a template for the button widget.
RenderButtonTemplate(ctx context.Context, req *http.Request) template.HTML
}

View File

@ -4,8 +4,8 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"github.com/1f349/lavender/auth/authContext"
"github.com/1f349/lavender/database" "github.com/1f349/lavender/database"
"html/template"
"net/http" "net/http"
) )
@ -38,10 +38,10 @@ type Provider interface {
Name() string Name() string
// RenderTemplate returns HTML to embed in the page template. // RenderTemplate returns HTML to embed in the page template.
RenderTemplate(ctx context.Context, req *http.Request, user *database.User) (template.HTML, error) RenderTemplate(ctx authContext.TemplateContext) error
// AttemptLogin processes the login request. // AttemptLogin processes the login request.
AttemptLogin(ctx context.Context, req *http.Request, user *database.User) error AttemptLogin(ctx authContext.TemplateContext) error
} }
type UserSafeError struct { type UserSafeError struct {

View File

@ -0,0 +1,48 @@
package authContext
import (
"context"
"github.com/1f349/lavender/database"
"net/http"
)
func NewTemplateContext(req *http.Request, user *database.User) TemplateContext {
return &BaseTemplateContext{
req: req,
user: user,
}
}
type TemplateContext interface {
Context() context.Context
Request() *http.Request
User() *database.User
Render(data any)
Data() any
}
type BaseTemplateContext struct {
req *http.Request
user *database.User
data any
}
func (t *BaseTemplateContext) Context() context.Context {
return t.req.Context()
}
func (t *BaseTemplateContext) Request() *http.Request {
return t.req
}
func (t *BaseTemplateContext) User() *database.User {
return t.user
}
func (t *BaseTemplateContext) Render(data any) {
t.data = data
}
func (t *BaseTemplateContext) Data() any {
return t.data
}

View File

@ -4,10 +4,9 @@ import (
"context" "context"
"database/sql" "database/sql"
"errors" "errors"
"fmt"
"github.com/1f349/lavender/auth" "github.com/1f349/lavender/auth"
"github.com/1f349/lavender/auth/authContext"
"github.com/1f349/lavender/database" "github.com/1f349/lavender/database"
"html/template"
"net/http" "net/http"
) )
@ -26,22 +25,36 @@ func (b *BasicLogin) AccessState() auth.State { return auth.StateUnauthorized }
func (b *BasicLogin) Name() string { return "basic" } func (b *BasicLogin) Name() string { return "basic" }
func (b *BasicLogin) RenderTemplate(ctx context.Context, req *http.Request, user *database.User) (template.HTML, error) { func (b *BasicLogin) RenderTemplate(ctx authContext.TemplateContext) error {
// TODO(melon): rewrite this // TODO(melon): rewrite this
return template.HTML(fmt.Sprintf("<div>%s</div>", req.FormValue("username"))), nil req := ctx.Request()
un := req.FormValue("login")
redirect := req.FormValue("redirect")
if redirect == "" {
redirect = "/"
}
ctx.Render(struct {
UserEmail string
Redirect string
}{
UserEmail: un,
Redirect: redirect,
})
return nil
} }
func (b *BasicLogin) AttemptLogin(ctx context.Context, req *http.Request, user *database.User) error { func (b *BasicLogin) AttemptLogin(ctx authContext.TemplateContext) error {
req := ctx.Request()
un := req.FormValue("username") un := req.FormValue("username")
pw := req.FormValue("password") pw := req.FormValue("password")
if len(pw) < 8 { if len(pw) < 8 {
return auth.BasicUserSafeError(http.StatusBadRequest, "Password too short") return auth.BasicUserSafeError(http.StatusBadRequest, "Password too short")
} }
login, err := b.DB.CheckLogin(ctx, un, pw) login, err := b.DB.CheckLogin(ctx.Context(), un, pw)
switch { switch {
case err == nil: case err == nil:
return auth.LookupUser(ctx, b.DB, login.Subject, user) return auth.LookupUser(ctx.Context(), b.DB, login.Subject, ctx.User())
case errors.Is(err, sql.ErrNoRows): case errors.Is(err, sql.ErrNoRows):
return auth.BasicUserSafeError(http.StatusForbidden, "Username or password is invalid") return auth.BasicUserSafeError(http.StatusForbidden, "Username or password is invalid")
default: default:

View File

@ -5,8 +5,10 @@ import (
"fmt" "fmt"
"github.com/1f349/cache" "github.com/1f349/cache"
"github.com/1f349/lavender/auth" "github.com/1f349/lavender/auth"
"github.com/1f349/lavender/auth/authContext"
"github.com/1f349/lavender/database" "github.com/1f349/lavender/database"
"github.com/1f349/lavender/issuer" "github.com/1f349/lavender/issuer"
"github.com/1f349/lavender/url"
"github.com/google/uuid" "github.com/google/uuid"
"golang.org/x/oauth2" "golang.org/x/oauth2"
"html/template" "html/template"
@ -20,12 +22,15 @@ type flowStateData struct {
redirect string redirect string
} }
var _ auth.Provider = (*OAuthLogin)(nil) var (
_ auth.Provider = (*OAuthLogin)(nil)
_ auth.Button = (*OAuthLogin)(nil)
)
type OAuthLogin struct { type OAuthLogin struct {
DB *database.Queries DB *database.Queries
BaseUrl string BaseUrl *url.URL
flow *cache.Cache[string, flowStateData] flow *cache.Cache[string, flowStateData]
} }
@ -34,29 +39,41 @@ func (o OAuthLogin) Init() {
o.flow = cache.New[string, flowStateData]() o.flow = cache.New[string, flowStateData]()
} }
func (o OAuthLogin) authUrlBase(ref string) *url.URL {
return o.BaseUrl.Resolve("oauth", o.Name(), ref)
}
func (o OAuthLogin) AccessState() auth.State { return auth.StateUnauthorized } func (o OAuthLogin) AccessState() auth.State { return auth.StateUnauthorized }
func (o OAuthLogin) Name() string { return "oauth" } func (o OAuthLogin) Name() string { return "oauth" }
func (o OAuthLogin) RenderTemplate(ctx context.Context, req *http.Request, user *database.User) (template.HTML, error) { func (o OAuthLogin) RenderTemplate(ctx authContext.TemplateContext) error {
return "<div>OAuth Login Template</div>", nil // TODO: does this need to exist?
ctx.Render(map[string]any{"Error": "no"})
return nil
} }
func (o OAuthLogin) AttemptLogin(ctx context.Context, req *http.Request, user *database.User) error { func (o OAuthLogin) AttemptLogin(ctx authContext.TemplateContext) error {
login, ok := ctx.Value(oauthServiceLogin(0)).(*issuer.WellKnownOIDC) rCtx := ctx.Context()
login, ok := rCtx.Value(oauthServiceLogin(0)).(*issuer.WellKnownOIDC)
if !ok { if !ok {
return fmt.Errorf("missing issuer wellknown") return fmt.Errorf("missing issuer wellknown")
} }
loginName := ctx.Value("login_full").(string) loginName := rCtx.Value("login_full").(string)
loginUn := ctx.Value("login_username").(string) loginUn := rCtx.Value("login_username").(string)
// save state for use later // save state for use later
state := login.Config.Namespace + ":" + uuid.NewString() state := login.Config.Namespace + ":" + uuid.NewString()
o.flow.Set(state, flowStateData{loginName, login, req.PostFormValue("redirect")}, time.Now().Add(15*time.Minute)) o.flow.Set(state, flowStateData{
loginName: loginName,
sso: login,
redirect: ctx.Request().PostFormValue("redirect"),
}, time.Now().Add(15*time.Minute))
// generate oauth2 config and redirect to authorize URL // generate oauth2 config and redirect to authorize URL
oa2conf := login.OAuth2Config oa2conf := login.OAuth2Config
oa2conf.RedirectURL = o.BaseUrl + "/callback" oa2conf.RedirectURL = o.authUrlBase("callback").String()
nextUrl := oa2conf.AuthCodeURL(state, oauth2.SetAuthURLParam("login_name", loginUn)) nextUrl := oa2conf.AuthCodeURL(state, oauth2.SetAuthURLParam("login_name", loginUn))
return auth.RedirectError{Target: nextUrl, Code: http.StatusFound} return auth.RedirectError{Target: nextUrl, Code: http.StatusFound}
@ -68,7 +85,7 @@ func (o OAuthLogin) OAuthCallback(rw http.ResponseWriter, req *http.Request, inf
http.Error(rw, "Invalid flow state", http.StatusBadRequest) http.Error(rw, "Invalid flow state", http.StatusBadRequest)
return return
} }
token, err := flowState.sso.OAuth2Config.Exchange(context.Background(), req.FormValue("code"), oauth2.SetAuthURLParam("redirect_uri", o.BaseUrl+"/callback")) token, err := flowState.sso.OAuth2Config.Exchange(context.Background(), req.FormValue("code"), oauth2.SetAuthURLParam("redirect_uri", o.authUrlBase("callback").String()))
if err != nil { if err != nil {
http.Error(rw, "Failed to exchange code for token", http.StatusInternalServerError) http.Error(rw, "Failed to exchange code for token", http.StatusInternalServerError)
return return
@ -90,6 +107,13 @@ func (o OAuthLogin) OAuthCallback(rw http.ResponseWriter, req *http.Request, inf
redirect(rw, req) redirect(rw, req)
} }
func (o OAuthLogin) ButtonName() string { return o.Name() }
func (o OAuthLogin) RenderButtonTemplate(ctx context.Context, req *http.Request) template.HTML {
// o.authUrlBase("button")
return "<div>OAuth Login Template</div>"
}
type oauthServiceLogin int type oauthServiceLogin int
func WithWellKnown(ctx context.Context, login *issuer.WellKnownOIDC) context.Context { func WithWellKnown(ctx context.Context, login *issuer.WellKnownOIDC) context.Context {

View File

@ -5,9 +5,9 @@ import (
"errors" "errors"
"fmt" "fmt"
"github.com/1f349/lavender/auth" "github.com/1f349/lavender/auth"
"github.com/1f349/lavender/auth/authContext"
"github.com/1f349/lavender/database" "github.com/1f349/lavender/database"
"github.com/xlzd/gotp" "github.com/xlzd/gotp"
"html/template"
"net/http" "net/http"
"time" "time"
) )
@ -30,19 +30,8 @@ func (o *OtpLogin) AccessState() auth.State { return auth.StateBasic }
func (o *OtpLogin) Name() string { return "basic" } func (o *OtpLogin) Name() string { return "basic" }
func (o *OtpLogin) RenderTemplate(_ context.Context, _ *http.Request, user *database.User) (template.HTML, error) { func (o *OtpLogin) RenderTemplate(ctx authContext.TemplateContext) error {
if user == nil || user.Subject == "" { user := ctx.User()
return "", fmt.Errorf("requires previous factor")
}
if user.OtpSecret == "" || !isDigitsSupported(user.OtpDigits) {
return "", fmt.Errorf("user does not support factor")
}
// no need to provide render data
return "<div>OTP login template</div>", nil
}
func (o *OtpLogin) AttemptLogin(ctx context.Context, req *http.Request, user *database.User) error {
if user == nil || user.Subject == "" { if user == nil || user.Subject == "" {
return fmt.Errorf("requires previous factor") return fmt.Errorf("requires previous factor")
} }
@ -50,7 +39,25 @@ func (o *OtpLogin) AttemptLogin(ctx context.Context, req *http.Request, user *da
return fmt.Errorf("user does not support factor") return fmt.Errorf("user does not support factor")
} }
code := req.FormValue("code") // TODO: is this right?
ctx.Render(map[string]any{
"Redirect": "/",
})
// no need to provide render data
return nil
}
func (o *OtpLogin) AttemptLogin(ctx authContext.TemplateContext) error {
user := ctx.User()
if user == nil || user.Subject == "" {
return fmt.Errorf("requires previous factor")
}
if user.OtpSecret == "" || !isDigitsSupported(user.OtpDigits) {
return fmt.Errorf("user does not support factor")
}
code := ctx.Request().FormValue("code")
if !validateTotp(user.OtpSecret, int(user.OtpDigits), code) { if !validateTotp(user.OtpSecret, int(user.OtpDigits), code) {
return auth.BasicUserSafeError(http.StatusBadRequest, "invalid OTP code") return auth.BasicUserSafeError(http.StatusBadRequest, "invalid OTP code")

View File

@ -4,7 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"github.com/1f349/lavender/auth" "github.com/1f349/lavender/auth"
"github.com/1f349/lavender/database" "github.com/1f349/lavender/auth/authContext"
"html/template" "html/template"
"net/http" "net/http"
) )
@ -13,7 +13,10 @@ type passkeyLoginDB interface {
auth.LookupUserDB auth.LookupUserDB
} }
var _ auth.Provider = (*PasskeyLogin)(nil) var (
_ auth.Provider = (*PasskeyLogin)(nil)
_ auth.Button = (*PasskeyLogin)(nil)
)
type PasskeyLogin struct { type PasskeyLogin struct {
DB passkeyLoginDB DB passkeyLoginDB
@ -23,12 +26,13 @@ func (p *PasskeyLogin) AccessState() auth.State { return auth.StateUnauthorized
func (p *PasskeyLogin) Name() string { return "passkey" } func (p *PasskeyLogin) Name() string { return "passkey" }
func (p *PasskeyLogin) RenderTemplate(ctx context.Context, req *http.Request, user *database.User) (template.HTML, error) { func (p *PasskeyLogin) RenderTemplate(ctx authContext.TemplateContext) error {
user := ctx.User()
if user == nil || user.Subject == "" { if user == nil || user.Subject == "" {
return "", fmt.Errorf("requires previous factor") return fmt.Errorf("requires previous factor")
} }
if user.OtpSecret == "" { if user.OtpSecret == "" {
return "", fmt.Errorf("user does not support factor") return fmt.Errorf("user does not support factor")
} }
panic("implement me") panic("implement me")
@ -40,7 +44,8 @@ func init() {
passkeyShortcut = true passkeyShortcut = true
} }
func (p *PasskeyLogin) AttemptLogin(ctx context.Context, req *http.Request, user *database.User) error { func (p *PasskeyLogin) AttemptLogin(ctx authContext.TemplateContext) error {
user := ctx.User()
if user.Subject == "" && !passkeyShortcut { if user.Subject == "" && !passkeyShortcut {
return fmt.Errorf("requires previous factor") return fmt.Errorf("requires previous factor")
} }
@ -48,3 +53,11 @@ func (p *PasskeyLogin) AttemptLogin(ctx context.Context, req *http.Request, user
//TODO implement me //TODO implement me
panic("implement me") panic("implement me")
} }
func (p *PasskeyLogin) ButtonName() string {
return "Login with Passkey"
}
func (p *PasskeyLogin) RenderButtonTemplate(ctx context.Context, req *http.Request) template.HTML {
return "<div>Passkey Button</div>"
}

View File

@ -2,12 +2,13 @@ package conf
import ( import (
"github.com/1f349/lavender/issuer" "github.com/1f349/lavender/issuer"
"github.com/1f349/lavender/url"
"github.com/1f349/simplemail" "github.com/1f349/simplemail"
) )
type Conf struct { type Conf struct {
Listen string `yaml:"listen"` Listen string `yaml:"listen"`
BaseUrl string `yaml:"baseUrl"` BaseUrl url.URL `yaml:"baseUrl"`
ServiceName string `yaml:"serviceName"` ServiceName string `yaml:"serviceName"`
Issuer string `yaml:"issuer"` Issuer string `yaml:"issuer"`
Kid string `yaml:"kid"` Kid string `yaml:"kid"`

View File

@ -28,7 +28,7 @@ func New(sender *simplemail.Mail, wd, name string) (*Mail, error) {
err := os.Mkdir(mailDir, os.ModePerm) err := os.Mkdir(mailDir, os.ModePerm)
if err == nil || errors.Is(err, os.ErrExist) { if err == nil || errors.Is(err, os.ErrExist) {
wdFs := os.DirFS(mailDir) wdFs := os.DirFS(mailDir)
o = overlapfs.OverlapFS{A: embeddedTemplates, B: wdFs} o = overlapfs.OverlapFS{A: o, B: wdFs}
} }
} }

View File

@ -1,8 +1,6 @@
package openid package openid
import ( import "github.com/1f349/lavender/url"
"strings"
)
type Config struct { type Config struct {
Issuer string `json:"issuer"` Issuer string `json:"issuer"`
@ -16,21 +14,17 @@ type Config struct {
JwksUri string `json:"jwks_uri"` JwksUri string `json:"jwks_uri"`
} }
func GenConfig(baseUrl string, scopes, claims []string) Config { func GenConfig(baseUrl *url.URL, scopes, claims []string) Config {
baseUrlRaw := baseUrl
if !strings.HasSuffix(baseUrl, "/") {
baseUrl += "/"
}
return Config{ return Config{
Issuer: baseUrlRaw, Issuer: baseUrl.String(),
AuthorizationEndpoint: baseUrl + "authorize", AuthorizationEndpoint: baseUrl.Resolve("authorize").String(),
TokenEndpoint: baseUrl + "token", TokenEndpoint: baseUrl.Resolve("token").String(),
UserInfoEndpoint: baseUrl + "userinfo", UserInfoEndpoint: baseUrl.Resolve("userinfo").String(),
ResponseTypesSupported: []string{"code"}, ResponseTypesSupported: []string{"code"},
ScopesSupported: scopes, ScopesSupported: scopes,
ClaimsSupported: claims, ClaimsSupported: claims,
GrantTypesSupported: []string{"authorization_code", "refresh_token"}, GrantTypesSupported: []string{"authorization_code", "refresh_token"},
JwksUri: baseUrl + ".well-known/jwks.json", JwksUri: baseUrl.Resolve(".well-known/jwks.json").String(),
} }
} }

View File

@ -1,6 +1,7 @@
package openid package openid
import ( import (
"github.com/1f349/lavender/url"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"testing" "testing"
) )
@ -16,5 +17,5 @@ func TestGenConfig(t *testing.T) {
ClaimsSupported: []string{"name", "email", "preferred_username"}, ClaimsSupported: []string{"name", "email", "preferred_username"},
GrantTypesSupported: []string{"authorization_code", "refresh_token"}, GrantTypesSupported: []string{"authorization_code", "refresh_token"},
JwksUri: "https://example.com/.well-known/jwks.json", JwksUri: "https://example.com/.well-known/jwks.json",
}, GenConfig("https://example.com", []string{"openid", "email"}, []string{"name", "email", "preferred_username"})) }, GenConfig(url.MustParse("https://example.com"), []string{"openid", "email"}, []string{"name", "email", "preferred_username"}))
} }

View File

@ -1,25 +1,29 @@
package server package server
import ( import (
"bytes"
"context" "context"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
auth2 "github.com/1f349/lavender/auth" "github.com/1f349/lavender/auth"
"github.com/1f349/lavender/auth/authContext"
"github.com/1f349/lavender/auth/providers" "github.com/1f349/lavender/auth/providers"
"github.com/1f349/lavender/database" "github.com/1f349/lavender/database"
"github.com/1f349/lavender/database/types" "github.com/1f349/lavender/database/types"
"github.com/1f349/lavender/issuer" "github.com/1f349/lavender/issuer"
"github.com/1f349/lavender/logger"
"github.com/1f349/lavender/web" "github.com/1f349/lavender/web"
"github.com/1f349/mjwt" "github.com/1f349/mjwt"
"github.com/1f349/mjwt/auth" mjwtAuth "github.com/1f349/mjwt/auth"
"github.com/golang-jwt/jwt/v4" "github.com/golang-jwt/jwt/v4"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/julienschmidt/httprouter" "github.com/julienschmidt/httprouter"
"github.com/mrmelon54/pronouns" "github.com/mrmelon54/pronouns"
"golang.org/x/oauth2" "golang.org/x/oauth2"
"golang.org/x/text/language" "golang.org/x/text/language"
"html/template"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
@ -42,7 +46,7 @@ func getUserLoginName(req *http.Request) string {
return originUrl.Query().Get("login_name") return originUrl.Query().Get("login_name")
} }
func (h *httpServer) testAuthSources(req *http.Request, user *database.User, factor auth2.State) map[string]bool { func (h *httpServer) testAuthSources(req *http.Request, user *database.User, factor auth.State) map[string]bool {
authSource := make(map[string]bool) authSource := make(map[string]bool)
data := make(map[string]any) data := make(map[string]any)
for _, i := range h.authSources { for _, i := range h.authSources {
@ -50,23 +54,46 @@ func (h *httpServer) testAuthSources(req *http.Request, user *database.User, fac
if i.AccessState() != factor { if i.AccessState() != factor {
continue continue
} }
page, err := i.RenderTemplate(req.Context(), req, user) err := i.RenderTemplate(authContext.NewTemplateContext(req, user))
_ = page
authSource[i.Name()] = err == nil authSource[i.Name()] = err == nil
clear(data) clear(data)
} }
return authSource return authSource
} }
func (h *httpServer) loginGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth auth2.UserAuth) { func (h *httpServer) getAuthWithState(state auth.State) auth.Provider {
if !auth.IsGuest() { for _, i := range h.authSources {
if i.AccessState() == state {
return i
}
}
return nil
}
func (h *httpServer) renderAuthTemplate(req *http.Request, provider auth.Provider) (template.HTML, error) {
tmpCtx := authContext.NewTemplateContext(req, new(database.User))
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() {
h.SafeRedirect(rw, req) h.SafeRedirect(rw, req)
return return
} }
cookie, err := req.Cookie("lavender-login-name") cookie, err := req.Cookie("lavender-login-name")
if err == nil && cookie.Valid() == nil { if err == nil && cookie.Valid() == nil {
user, err := h.db.GetUser(req.Context(), auth.Subject) user, err := h.db.GetUser(req.Context(), userAuth.Subject)
var userPtr *database.User var userPtr *database.User
switch { switch {
case err == nil: case err == nil:
@ -78,30 +105,55 @@ func (h *httpServer) loginGet(rw http.ResponseWriter, req *http.Request, _ httpr
return return
} }
fmt.Printf("%#v\n", h.testAuthSources(req, userPtr, auth2.StateBasic)) fmt.Printf("%#v\n", h.testAuthSources(req, userPtr, auth.StateBasic))
web.RenderPageTemplate(rw, "login-memory", map[string]any{ web.RenderPageTemplate(rw, "login-memory", map[string]any{
"ServiceName": h.conf.ServiceName, "ServiceName": h.conf.ServiceName,
"LoginName": cookie.Value, "LoginName": cookie.Value,
"Redirect": req.URL.Query().Get("redirect"), "Redirect": req.URL.Query().Get("redirect"),
"Source": "start", "Source": "start",
"Auth": h.testAuthSources(req, userPtr, auth2.StateBasic), "Auth": h.testAuthSources(req, userPtr, auth.StateBasic),
}) })
return return
} }
buttonTemplates := make([]template.HTML, len(h.authButtons))
for i := range h.authButtons {
buttonTemplates[i] = h.authButtons[i].RenderButtonTemplate(req.Context(), req)
}
type loginError struct {
Error string `json:"error"`
}
var renderTemplate template.HTML
provider := h.getAuthWithState(auth.StateUnauthorized)
// Maybe the admin has disabled some login providers but does have a button based provider available?
if provider != nil {
renderTemplate, err = h.renderAuthTemplate(req, provider)
if err != nil {
logger.Logger.Warn("No provider for login")
web.RenderPageTemplate(rw, "login-error", loginError{Error: "No available provider for login"})
return
}
}
// render different page sources // render different page sources
web.RenderPageTemplate(rw, "login", map[string]any{ web.RenderPageTemplate(rw, "login", map[string]any{
"ServiceName": h.conf.ServiceName, "ServiceName": h.conf.ServiceName,
"LoginName": "", "LoginName": "",
"Redirect": req.URL.Query().Get("redirect"), "Redirect": req.URL.Query().Get("redirect"),
"Source": "start", "Source": "start",
"Auth": h.testAuthSources(req, nil, auth2.StateBasic), "Auth": h.testAuthSources(req, nil, auth.StateUnauthorized),
"AuthTemplate": renderTemplate,
"AuthButtons": buttonTemplates,
}) })
} }
func (h *httpServer) loginPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth auth2.UserAuth) { func (h *httpServer) loginPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth2 auth.UserAuth) {
if !auth.IsGuest() { if !auth2.IsGuest() {
h.SafeRedirect(rw, req) h.SafeRedirect(rw, req)
return return
} }
@ -120,7 +172,7 @@ func (h *httpServer) loginPost(rw http.ResponseWriter, req *http.Request, _ http
}).String(), http.StatusFound) }).String(), http.StatusFound)
return return
} }
loginName := req.PostFormValue("loginname") loginName := req.PostFormValue("email")
// append local namespace if @ is missing // append local namespace if @ is missing
n := strings.IndexByte(loginName, '@') n := strings.IndexByte(loginName, '@')
@ -156,12 +208,16 @@ func (h *httpServer) loginPost(rw http.ResponseWriter, req *http.Request, _ http
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
}) })
var redirectError auth2.RedirectError var redirectError auth.RedirectError
// TODO(melon): rewrite login system here
// if the login is the local server // if the login is the local server
if login == issuer.MeWellKnown { if login == issuer.MeWellKnown {
// TODO(melon): work on this // TODO(melon): work on this
err := h.authBasic.AttemptLogin(ctx, req, nil) // TODO: rewrite
//err := h.authBasic.AttemptLogin(ctx, req, nil)
var err error
switch { switch {
case errors.As(err, &redirectError): case errors.As(err, &redirectError):
http.Redirect(rw, req, redirectError.Target, redirectError.Code) http.Redirect(rw, req, redirectError.Target, redirectError.Code)
@ -170,7 +226,9 @@ func (h *httpServer) loginPost(rw http.ResponseWriter, req *http.Request, _ http
return return
} }
err := h.authOAuth.AttemptLogin(ctx, req, nil) // TODO: rewrite
//err := h.authOAuth.AttemptLogin(ctx, req, nil)
var err error
switch { switch {
case errors.As(err, &redirectError): case errors.As(err, &redirectError):
http.Redirect(rw, req, redirectError.Target, redirectError.Code) http.Redirect(rw, req, redirectError.Target, redirectError.Code)
@ -178,14 +236,15 @@ func (h *httpServer) loginPost(rw http.ResponseWriter, req *http.Request, _ http
} }
} }
func (h *httpServer) loginCallback(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, userAuth auth2.UserAuth) { func (h *httpServer) loginCallback(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, _ auth.UserAuth) {
h.authOAuth.OAuthCallback(rw, req, h.updateExternalUserInfo, h.setLoginDataCookie, h.SafeRedirect) // TODO: rewrite
//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) { func (h *httpServer) updateExternalUserInfo(req *http.Request, sso *issuer.WellKnownOIDC, token *oauth2.Token) (auth.UserAuth, error) {
sessionData, err := h.fetchUserInfo(sso, token) sessionData, err := h.fetchUserInfo(sso, token)
if err != nil || sessionData.Subject == "" { if err != nil || sessionData.Subject == "" {
return auth2.UserAuth{}, fmt.Errorf("failed to fetch user info") return auth.UserAuth{}, fmt.Errorf("failed to fetch user info")
} }
// TODO(melon): fix this to use a merging of lavender and tulip auth // TODO(melon): fix this to use a merging of lavender and tulip auth
@ -206,9 +265,9 @@ func (h *httpServer) updateExternalUserInfo(req *http.Request, sso *issuer.WellK
err = h.DbTxError(func(tx *database.Queries) error { err = h.DbTxError(func(tx *database.Queries) error {
return h.updateOAuth2UserProfile(req.Context(), tx, sessionData) return h.updateOAuth2UserProfile(req.Context(), tx, sessionData)
}) })
return auth2.UserAuth{ return auth.UserAuth{
Subject: userSubject, Subject: userSubject,
Factor: auth2.StateExtended, Factor: auth.StateExtended,
UserInfo: sessionData.UserInfo, UserInfo: sessionData.UserInfo,
}, err }, err
case errors.Is(err, sql.ErrNoRows): case errors.Is(err, sql.ErrNoRows):
@ -216,12 +275,12 @@ func (h *httpServer) updateExternalUserInfo(req *http.Request, sso *issuer.WellK
break break
default: default:
// another error occurred // another error occurred
return auth2.UserAuth{}, err return auth.UserAuth{}, err
} }
// guard for disabled registration // guard for disabled registration
if !sso.Config.Registration { if !sso.Config.Registration {
return auth2.UserAuth{}, fmt.Errorf("registration is not enabled for this authentication source") return auth.UserAuth{}, fmt.Errorf("registration is not enabled for this authentication source")
} }
// TODO(melon): rework this // TODO(melon): rework this
@ -246,7 +305,7 @@ func (h *httpServer) updateExternalUserInfo(req *http.Request, sso *issuer.WellK
return h.updateOAuth2UserProfile(req.Context(), tx, sessionData) return h.updateOAuth2UserProfile(req.Context(), tx, sessionData)
}) })
if err != nil { if err != nil {
return auth2.UserAuth{}, err return auth.UserAuth{}, err
} }
// only continues if the above tx succeeds // only continues if the above tx succeeds
@ -258,20 +317,20 @@ func (h *httpServer) updateExternalUserInfo(req *http.Request, sso *issuer.WellK
Subject: sessionData.Subject, Subject: sessionData.Subject,
}) })
}); err != nil { }); err != nil {
return auth2.UserAuth{}, err return auth.UserAuth{}, err
} }
// TODO(melon): this feels bad // TODO(melon): this feels bad
sessionData = auth2.UserAuth{ sessionData = auth.UserAuth{
Subject: userSubject, Subject: userSubject,
Factor: auth2.StateExtended, Factor: auth.StateExtended,
UserInfo: sessionData.UserInfo, UserInfo: sessionData.UserInfo,
} }
return sessionData, nil return sessionData, nil
} }
func (h *httpServer) updateOAuth2UserProfile(ctx context.Context, tx *database.Queries, sessionData auth2.UserAuth) error { func (h *httpServer) updateOAuth2UserProfile(ctx context.Context, tx *database.Queries, sessionData auth.UserAuth) error {
// all of these updates must succeed // all of these updates must succeed
return tx.UseTx(ctx, func(tx *database.Queries) error { return tx.UseTx(ctx, func(tx *database.Queries) error {
name := sessionData.UserInfo.GetStringOrDefault("name", "Unknown User") name := sessionData.UserInfo.GetStringOrDefault("name", "Unknown User")
@ -312,9 +371,9 @@ const twelveHours = 12 * time.Hour
const oneWeek = 7 * 24 * time.Hour const oneWeek = 7 * 24 * time.Hour
type lavenderLoginAccess struct { type lavenderLoginAccess struct {
UserInfo auth2.UserInfoFields `json:"user_info"` UserInfo auth.UserInfoFields `json:"user_info"`
Factor auth2.State `json:"factor"` Factor auth.State `json:"factor"`
auth.AccessTokenClaims mjwtAuth.AccessTokenClaims
} }
func (l lavenderLoginAccess) Valid() error { return l.AccessTokenClaims.Valid() } func (l lavenderLoginAccess) Valid() error { return l.AccessTokenClaims.Valid() }
@ -323,28 +382,28 @@ func (l lavenderLoginAccess) Type() string { return "lavender-login-access" }
type lavenderLoginRefresh struct { type lavenderLoginRefresh struct {
Login string `json:"login"` Login string `json:"login"`
auth.RefreshTokenClaims mjwtAuth.RefreshTokenClaims
} }
func (l lavenderLoginRefresh) Valid() error { return l.RefreshTokenClaims.Valid() } func (l lavenderLoginRefresh) Valid() error { return l.RefreshTokenClaims.Valid() }
func (l lavenderLoginRefresh) Type() string { return "lavender-login-refresh" } func (l lavenderLoginRefresh) Type() string { return "lavender-login-refresh" }
func (h *httpServer) setLoginDataCookie(rw http.ResponseWriter, authData auth2.UserAuth, loginName string) bool { func (h *httpServer) setLoginDataCookie(rw http.ResponseWriter, authData auth.UserAuth, loginName string) bool {
ps := auth.NewPermStorage() ps := mjwtAuth.NewPermStorage()
accId := uuid.NewString() accId := uuid.NewString()
gen, err := h.signingKey.GenerateJwt(authData.Subject, accId, jwt.ClaimStrings{h.conf.BaseUrl}, twelveHours, lavenderLoginAccess{ gen, err := h.signingKey.GenerateJwt(authData.Subject, accId, jwt.ClaimStrings{h.conf.BaseUrl.String()}, twelveHours, lavenderLoginAccess{
UserInfo: authData.UserInfo, UserInfo: authData.UserInfo,
Factor: authData.Factor, Factor: authData.Factor,
AccessTokenClaims: auth.AccessTokenClaims{Perms: ps}, AccessTokenClaims: mjwtAuth.AccessTokenClaims{Perms: ps},
}) })
if err != nil { if err != nil {
http.Error(rw, "Failed to generate cookie token", http.StatusInternalServerError) http.Error(rw, "Failed to generate cookie token", http.StatusInternalServerError)
return true return true
} }
ref, err := h.signingKey.GenerateJwt(authData.Subject, uuid.NewString(), jwt.ClaimStrings{h.conf.BaseUrl}, oneWeek, lavenderLoginRefresh{ ref, err := h.signingKey.GenerateJwt(authData.Subject, uuid.NewString(), jwt.ClaimStrings{h.conf.BaseUrl.String()}, oneWeek, lavenderLoginRefresh{
Login: loginName, Login: loginName,
RefreshTokenClaims: auth.RefreshTokenClaims{AccessTokenId: accId}, RefreshTokenClaims: mjwtAuth.RefreshTokenClaims{AccessTokenId: accId},
}) })
if err != nil { if err != nil {
http.Error(rw, "Failed to generate cookie token", http.StatusInternalServerError) http.Error(rw, "Failed to generate cookie token", http.StatusInternalServerError)
@ -382,12 +441,12 @@ func readJwtCookie[T mjwt.Claims](req *http.Request, cookieName string, signingK
return b, nil return b, nil
} }
func (h *httpServer) readLoginAccessCookie(rw http.ResponseWriter, req *http.Request, u *auth2.UserAuth) error { 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()) loginData, err := readJwtCookie[lavenderLoginAccess](req, "lavender-login-access", h.signingKey.KeyStore())
if err != nil { if err != nil {
return h.readLoginRefreshCookie(rw, req, u) return h.readLoginRefreshCookie(rw, req, u)
} }
*u = auth2.UserAuth{ *u = auth.UserAuth{
Subject: loginData.Subject, Subject: loginData.Subject,
Factor: loginData.Claims.Factor, Factor: loginData.Claims.Factor,
UserInfo: loginData.Claims.UserInfo, UserInfo: loginData.Claims.UserInfo,
@ -395,7 +454,7 @@ func (h *httpServer) readLoginAccessCookie(rw http.ResponseWriter, req *http.Req
return nil return nil
} }
func (h *httpServer) readLoginRefreshCookie(rw http.ResponseWriter, req *http.Request, userAuth *auth2.UserAuth) error { 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()) refreshData, err := readJwtCookie[lavenderLoginRefresh](req, "lavender-login-refresh", h.signingKey.KeyStore())
if err != nil { if err != nil {
return err return err
@ -433,28 +492,28 @@ func (h *httpServer) readLoginRefreshCookie(rw http.ResponseWriter, req *http.Re
return nil return nil
} }
func (h *httpServer) fetchUserInfo(sso *issuer.WellKnownOIDC, token *oauth2.Token) (auth2.UserAuth, error) { func (h *httpServer) fetchUserInfo(sso *issuer.WellKnownOIDC, token *oauth2.Token) (auth.UserAuth, error) {
res, err := sso.OAuth2Config.Client(context.Background(), token).Get(sso.UserInfoEndpoint) res, err := sso.OAuth2Config.Client(context.Background(), token).Get(sso.UserInfoEndpoint)
if err != nil || res.StatusCode != http.StatusOK { if err != nil || res.StatusCode != http.StatusOK {
return auth2.UserAuth{}, fmt.Errorf("request failed") return auth.UserAuth{}, fmt.Errorf("request failed")
} }
defer res.Body.Close() defer res.Body.Close()
var userInfoJson auth2.UserInfoFields var userInfoJson auth.UserInfoFields
if err := json.NewDecoder(res.Body).Decode(&userInfoJson); err != nil { if err := json.NewDecoder(res.Body).Decode(&userInfoJson); err != nil {
return auth2.UserAuth{}, err return auth.UserAuth{}, err
} }
subject, ok := userInfoJson.GetString("sub") subject, ok := userInfoJson.GetString("sub")
if !ok { if !ok {
return auth2.UserAuth{}, fmt.Errorf("invalid subject") return auth.UserAuth{}, fmt.Errorf("invalid subject")
} }
// TODO(melon): there is no need for this // TODO(melon): there is no need for this
//subject += "@" + sso.Config.Namespace //subject += "@" + sso.Config.Namespace
return auth2.UserAuth{ return auth.UserAuth{
Subject: subject, Subject: subject,
Factor: auth2.StateExtended, Factor: auth.StateExtended,
UserInfo: userInfoJson, UserInfo: userInfoJson,
}, nil }, nil
} }

View File

@ -25,6 +25,9 @@ import (
"time" "time"
) )
// TODO(melon): add ldap client, radius client and other login support
// TODO(melon): add ldap server, radius server support
func SetupOAuth2(r *httprouter.Router, hs *httpServer, key *mjwt.Issuer, db *database.Queries) { func SetupOAuth2(r *httprouter.Router, hs *httpServer, key *mjwt.Issuer, db *database.Queries) {
oauthManager := manage.NewDefaultManager() oauthManager := manage.NewDefaultManager()
oauthManager.MapAuthorizeGenerate(generates.NewAuthorizeGenerate()) oauthManager.MapAuthorizeGenerate(generates.NewAuthorizeGenerate())

View File

@ -5,12 +5,13 @@ import (
"encoding/json" "encoding/json"
"github.com/1f349/lavender/logger" "github.com/1f349/lavender/logger"
"github.com/1f349/lavender/openid" "github.com/1f349/lavender/openid"
"github.com/1f349/lavender/url"
"github.com/1f349/mjwt" "github.com/1f349/mjwt"
"github.com/julienschmidt/httprouter" "github.com/julienschmidt/httprouter"
"net/http" "net/http"
) )
func SetupOpenId(r *httprouter.Router, baseUrl string, signingKey *mjwt.Issuer) { func SetupOpenId(r *httprouter.Router, baseUrl *url.URL, signingKey *mjwt.Issuer) {
openIdConf := openid.GenConfig(baseUrl, []string{ openIdConf := openid.GenConfig(baseUrl, []string{
"openid", "name", "username", "profile", "email", "birthdate", "age", "zoneinfo", "locale", "openid", "name", "username", "profile", "email", "birthdate", "age", "zoneinfo", "locale",
}, []string{ }, []string{

View File

@ -26,7 +26,10 @@ func (h *httpServer) editOtpPost(rw http.ResponseWriter, req *http.Request, _ ht
} }
otpInput := req.Form.Get("code") otpInput := req.Form.Get("code")
err := h.authOtp.VerifyOtpCode(req.Context(), auth.Subject, otpInput) _ = otpInput
// TODO: rewrite
//err := h.authOtp.VerifyOtpCode(req.Context(), auth.Subject, otpInput)
var err error
if err != nil { if err != nil {
http.Error(rw, "Invalid OTP code", http.StatusBadRequest) http.Error(rw, "Invalid OTP code", http.StatusBadRequest)
return return

View File

@ -35,11 +35,8 @@ type httpServer struct {
// mailLinkCache contains a mapping of verify uuids to user uuids // mailLinkCache contains a mapping of verify uuids to user uuids
mailLinkCache *cache.Cache[mailLinkKey, string] mailLinkCache *cache.Cache[mailLinkKey, string]
authBasic *providers.BasicLogin
authOtp *providers.OtpLogin
authOAuth *providers.OAuthLogin
authSources []auth.Provider authSources []auth.Provider
authButtons []auth.Button
} }
type mailLink byte type mailLink byte
@ -56,13 +53,26 @@ type mailLinkKey struct {
} }
func SetupRouter(r *httprouter.Router, config conf.Conf, mailSender *mail.Mail, db *database.Queries, signingKey *mjwt.Issuer) { func SetupRouter(r *httprouter.Router, config conf.Conf, mailSender *mail.Mail, db *database.Queries, signingKey *mjwt.Issuer) {
// remove last slash from baseUrl // TODO: move auth provider init to main function
config.BaseUrl = strings.TrimRight(config.BaseUrl, "/") // TODO: allow dynamically changing the providers based on database information
authBasic := &providers.BasicLogin{DB: db} authBasic := &providers.BasicLogin{DB: db}
authOtp := &providers.OtpLogin{DB: db} authOtp := &providers.OtpLogin{DB: db}
authOAuth := &providers.OAuthLogin{DB: db, BaseUrl: config.BaseUrl} authOAuth := &providers.OAuthLogin{DB: db, BaseUrl: &config.BaseUrl}
authOAuth.Init() authOAuth.Init()
authPasskey := &providers.PasskeyLogin{DB: db}
authSources := []auth.Provider{
authBasic,
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{ hs := &httpServer{
r: r, r: r,
@ -73,15 +83,8 @@ func SetupRouter(r *httprouter.Router, config conf.Conf, mailSender *mail.Mail,
mailLinkCache: cache.New[mailLinkKey, string](), mailLinkCache: cache.New[mailLinkKey, string](),
authBasic: authBasic, authSources: authSources,
authOtp: authOtp, authButtons: authButtons,
authOAuth: authOAuth,
//authPasskey: &auth.PasskeyLogin{DB: db},
authSources: []auth.Provider{
authBasic,
authOtp,
},
} }
var err error var err error
@ -90,7 +93,7 @@ func SetupRouter(r *httprouter.Router, config conf.Conf, mailSender *mail.Mail,
logger.Logger.Fatal("Failed to load SSO services", "err", err) logger.Logger.Fatal("Failed to load SSO services", "err", err)
} }
SetupOpenId(r, config.BaseUrl, signingKey) SetupOpenId(r, &config.BaseUrl, signingKey)
r.GET("/", hs.OptionalAuthentication(false, hs.Home)) r.GET("/", hs.OptionalAuthentication(false, hs.Home))
r.POST("/logout", hs.RequireAuthentication(hs.logoutPost)) r.POST("/logout", hs.RequireAuthentication(hs.logoutPost))

BIN
tmp/main Normal file

Binary file not shown.

40
url/url.go Normal file
View File

@ -0,0 +1,40 @@
package url
import (
"encoding"
"net/url"
"path"
)
type URL struct {
url.URL
}
func (u *URL) Resolve(paths ...string) *URL {
return &URL{URL: *u.URL.ResolveReference(&url.URL{Path: path.Join(paths...)})}
}
func (u URL) MarshalText() (text []byte, err error) {
return []byte(u.String()), nil
}
func (u *URL) UnmarshalText(text []byte) error {
parse, err := u.Parse(string(text))
if err != nil {
return err
}
u.URL = *parse
return nil
}
var _ encoding.TextMarshaler = (*URL)(nil)
var _ encoding.TextUnmarshaler = (*URL)(nil)
func MustParse(rawURL string) *URL {
u, err := url.Parse(rawURL)
if err != nil {
panic(err)
}
return &URL{*u}
}

View File

@ -7,9 +7,12 @@ import svelte from '@astrojs/svelte';
// https://astro.build/config // https://astro.build/config
export default defineConfig({ export default defineConfig({
integrations: [tailwind({ integrations: [
nesting: true, tailwind({
}), svelte()], nesting: true,
}),
svelte({extensions: ['.svelte']}),
],
build: { build: {
format: 'file', format: 'file',
}, },

View File

@ -0,0 +1,7 @@
<script lang="ts">
function loginWithPasskey() {
alert("Sign in with Passkey");
}
</script>
<a on:click={loginWithPasskey}>Sign in with Passkey</a>

View File

@ -0,0 +1,5 @@
---
export const partial = true;
---
<a href="[[ .Href ]]">[[ .ButtonName ]]</a>

View File

@ -0,0 +1,7 @@
---
export const partial = true;
import PasskeyButton from '../../components/auth-buttons/PasskeyButton.svelte';
---
<PasskeyButton client:only="svelte"/>

View File

@ -0,0 +1,16 @@
---
export const partial = true;
---
<form method="POST" action="/login">
<input type="hidden" name="redirect" value="[[.Redirect]]"/>
<div>
<label for="field_email">Email:</label>
<input type="email" name="email" id="field_email" value="[[.UserEmail]]" required/>
</div>
<div>
<label for="field_password">Password:</label>
<input type="password" name="password" id="field_password" autofocus required/>
</div>
<button type="submit">Login</button>
</form>

View File

@ -1,26 +0,0 @@
---
export const partial = true;
---
<form method="POST" action="/login">
<input type="hidden" name="redirect" value="[[.Redirect]]"/>
<div>
<label for="field_loginname">Login Name:</label>
<input type="text" name="loginname" id="field_loginname" value="[[.LoginName]]" required/>
</div>
<div>
<label for="field_password">Password:</label>
<input type="password" name="password" id="field_password" autofocus required/>
</div>
<button type="submit">Login</button>
</form>
<form method="POST" action="/reset-password">
<p>Enter your email address below to receive an email with instructions on how to reset your password.</p>
<p>Please note this only works if your email address is already verified.</p>
<div>
<label for="field_email">Email:</label>
<input type="email" name="email" id="field_email" required/>
</div>
<button type="submit">Send Reset Password Email</button>
</form>

View File

@ -0,0 +1,12 @@
---
export const partial = true;
---
<form method="POST" action="/login">
<input type="hidden" name="redirect" value="[[.Redirect]]"/>
<div>
<label for="field_email">Email:</label>
<input type="email" name="email" id="field_email" value="[[.UserEmail]]" required/>
</div>
<button type="submit">Login</button>
</form>

View File

@ -9,6 +9,17 @@ import Layout from "../layouts/Layout.astro";
<p>Check your inbox for a verification email</p> <p>Check your inbox for a verification email</p>
[[ end ]] [[ end ]]
[[ .AuthTemplate ]] [[ .AuthTemplate ]]
<div>
<form method="POST" action="/reset-password">
<p>Enter your email address below to receive an email with instructions on how to reset your password.</p>
<p>Please note this only works if your email address is already verified.</p>
<div>
<label for="field_email">Email:</label>
<input type="email" name="email" id="field_email" required/>
</div>
<button type="submit">Send Reset Password Email</button>
</form>
</div>
[[ if gt (len .AuthButtons) 0 ]] [[ if gt (len .AuthButtons) 0 ]]
<div class="auth-buttons"> <div class="auth-buttons">
[[ range $authButton := .AuthButtons ]] [[ range $authButton := .AuthButtons ]]
@ -16,9 +27,4 @@ import Layout from "../layouts/Layout.astro";
[[ end ]] [[ end ]]
</div> </div>
[[ end ]] [[ end ]]
<!--
<div style="display: none;">
<button id="start-passkey-auth">Sign in with a passkey</button>
</div>
-->
</Layout> </Layout>

View File

@ -18,7 +18,7 @@ import (
var ( var (
//go:embed dist //go:embed dist
webBuild embed.FS webDist embed.FS
webCombinedDir fs.FS webCombinedDir fs.FS
pageTemplates *template.Template pageTemplates *template.Template
@ -27,6 +27,11 @@ var (
func LoadPages(wd string) error { func LoadPages(wd string) error {
return loadOnce.Do(func() (err error) { return loadOnce.Do(func() (err error) {
webBuild, err := fs.Sub(webDist, "dist")
if err != nil {
return err
}
webCombinedDir = webBuild webCombinedDir = webBuild
if wd != "" { if wd != "" {
@ -44,16 +49,39 @@ func LoadPages(wd string) error {
// TODO(melon): figure this out layer // TODO(melon): figure this out layer
webCombinedDir = webBuild webCombinedDir = webBuild
pageTemplates, err = template.New("web").Delims("[[", "]]").Funcs(template.FuncMap{ pageTemplates, err = findAndParseTemplates(webCombinedDir, template.FuncMap{
"emailHide": utils.EmailHide, "emailHide": utils.EmailHide,
"renderOptionTag": renderOptionTag, "renderOptionTag": renderOptionTag,
"renderCheckboxTag": renderCheckboxTag, "renderCheckboxTag": renderCheckboxTag,
}).ParseFS(webCombinedDir, "dist/*.html") })
return err return err
}) })
} }
func findAndParseTemplates(rootDir fs.FS, funcMap template.FuncMap) (*template.Template, error) {
root := template.New("")
err := fs.WalkDir(rootDir, ".", func(p string, d fs.DirEntry, e1 error) error {
if d.IsDir() || !strings.HasSuffix(p, ".html") {
return nil
}
if e1 != nil {
return e1
}
fileContents, err := fs.ReadFile(webCombinedDir, p)
if err != nil {
return err
}
t := root.New(p).Delims("[[", "]]").Funcs(funcMap)
_, err = t.Parse(string(fileContents))
return err
})
return root, err
}
func renderOptionTag(value, display string, selectedValue string) template.HTML { func renderOptionTag(value, display string, selectedValue string) template.HTML {
var selectedParam string var selectedParam string
if value == selectedValue { if value == selectedValue {
@ -70,12 +98,16 @@ func renderCheckboxTag(name, id string, checked bool) template.HTML {
return template.HTML("<input type=\"checkbox\" name=\"" + html.EscapeString(name) + "\" id=\"" + html.EscapeString(id) + "\"" + checkedParam + " />") return template.HTML("<input type=\"checkbox\" name=\"" + html.EscapeString(name) + "\" id=\"" + html.EscapeString(id) + "\"" + checkedParam + " />")
} }
func RenderPageTemplate(wr io.Writer, name string, data any) { func RenderPageTemplate(wr io.Writer, name string, data any) bool {
logger.Logger.Helper()
p := name + ".html" p := name + ".html"
err := pageTemplates.ExecuteTemplate(wr, p, data) err := pageTemplates.ExecuteTemplate(wr, p, data)
if err != nil { if err != nil {
logger.Logger.Warn("Failed to render page", "name", name, "err", err) logger.Logger.Warn("Failed to render page", "name", name, "err", err)
} }
return err == nil
} }
func RenderWebAsset(rw http.ResponseWriter, req *http.Request, name string) { func RenderWebAsset(rw http.ResponseWriter, req *http.Request, name string) {

View File

@ -1,16 +1,20 @@
package web package web
import ( import (
"embed"
"fmt" "fmt"
"github.com/1f349/lavender/utils" "github.com/1f349/lavender/utils"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"html/template" "html/template"
"io/fs" "io/fs"
"path"
"slices"
"strings"
"testing" "testing"
) )
func TestLoadPages_FindErrors(t *testing.T) { func TestLoadPages_FindErrors(t *testing.T) {
glob, err := fs.Glob(webBuild, "dist/*/index.html") glob, err := fs.Glob(webDist, "dist/*/index.html")
assert.NoError(t, err) assert.NoError(t, err)
fmt.Println(glob) fmt.Println(glob)
@ -21,8 +25,46 @@ func TestLoadPages_FindErrors(t *testing.T) {
"emailHide": utils.EmailHide, "emailHide": utils.EmailHide,
"renderOptionTag": renderOptionTag, "renderOptionTag": renderOptionTag,
"renderCheckboxTag": renderCheckboxTag, "renderCheckboxTag": renderCheckboxTag,
}).ParseFS(webBuild, fileName) }).ParseFS(webDist, fileName)
assert.NoError(t, err) assert.NoError(t, err)
}) })
} }
} }
//go:embed src/pages
var webSrcPages embed.FS
func TestLoadPage_FindMissing(t *testing.T) {
paths := make([]string, 0)
err := fs.WalkDir(webSrcPages, "src/pages", func(p string, d fs.DirEntry, err error) error {
if d.IsDir() {
return nil
}
if strings.HasSuffix(path.Base(p), ".astro") {
p = strings.TrimPrefix(p, "src/pages/")
p = strings.TrimSuffix(p, ".astro")
p += ".html"
paths = append(paths, p)
}
return nil
})
assert.NoError(t, err)
slices.Sort(paths)
err = LoadPages("")
assert.NoError(t, err)
tmpls := make([]string, 0)
for _, i := range pageTemplates.Templates() {
if i.Name() == "" {
continue
}
tmpls = append(tmpls, i.Name())
}
slices.Sort(tmpls)
assert.ElementsMatch(t, paths, tmpls)
}