mirror of
https://github.com/1f349/lavender.git
synced 2025-01-21 06:06:30 +00:00
Oh dear
This commit is contained in:
parent
15540ef16b
commit
0df2cf6681
15
auth/auth-buttons.go
Normal file
15
auth/auth-buttons.go
Normal 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
|
||||||
|
}
|
@ -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 {
|
||||||
|
48
auth/authContext/context.go
Normal file
48
auth/authContext/context.go
Normal 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
|
||||||
|
}
|
@ -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:
|
@ -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 {
|
||||||
|
@ -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")
|
||||||
|
@ -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>"
|
||||||
|
}
|
||||||
|
@ -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"`
|
||||||
|
@ -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}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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"}))
|
||||||
}
|
}
|
||||||
|
159
server/login.go
159
server/login.go
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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())
|
||||||
|
@ -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{
|
||||||
|
@ -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
|
||||||
|
@ -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))
|
||||||
|
|
||||||
|
40
url/url.go
Normal file
40
url/url.go
Normal 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}
|
||||||
|
}
|
@ -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: [
|
||||||
|
tailwind({
|
||||||
nesting: true,
|
nesting: true,
|
||||||
}), svelte()],
|
}),
|
||||||
|
svelte({extensions: ['.svelte']}),
|
||||||
|
],
|
||||||
build: {
|
build: {
|
||||||
format: 'file',
|
format: 'file',
|
||||||
},
|
},
|
||||||
|
7
web/src/components/auth-buttons/PasskeyButton.svelte
Normal file
7
web/src/components/auth-buttons/PasskeyButton.svelte
Normal 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>
|
5
web/src/pages/auth-buttons/oauth.astro
Normal file
5
web/src/pages/auth-buttons/oauth.astro
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
export const partial = true;
|
||||||
|
---
|
||||||
|
|
||||||
|
<a href="[[ .Href ]]">[[ .ButtonName ]]</a>
|
7
web/src/pages/auth-buttons/passkey.astro
Normal file
7
web/src/pages/auth-buttons/passkey.astro
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
export const partial = true;
|
||||||
|
|
||||||
|
import PasskeyButton from '../../components/auth-buttons/PasskeyButton.svelte';
|
||||||
|
---
|
||||||
|
|
||||||
|
<PasskeyButton client:only="svelte"/>
|
16
web/src/pages/auth/basic.astro
Normal file
16
web/src/pages/auth/basic.astro
Normal 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>
|
@ -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>
|
|
12
web/src/pages/auth/sso.astro
Normal file
12
web/src/pages/auth/sso.astro
Normal 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>
|
@ -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>
|
||||||
|
42
web/web.go
42
web/web.go
@ -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) {
|
||||||
|
@ -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)
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user