Rewrite login system

This commit is contained in:
Melon 2024-12-09 18:40:18 +00:00
parent 5e36254aaf
commit 5607bd3a97
Signed by: melon
GPG Key ID: 6C9D970C50D26A25
12 changed files with 87 additions and 159 deletions

View File

@ -25,13 +25,9 @@ const (
StateSudo StateSudo
) )
func IsLoggedIn(s State) bool { func (s State) IsLoggedIn() bool { return s >= StateExtended }
return s >= StateExtended
}
func IsSudoAvailable(s State) bool { func (s State) IsSudoAvailable() bool { return s == StateSudo }
return s == StateSudo
}
type Provider interface { type Provider interface {
// AccessState defines the state at which the provider is allowed to show. // AccessState defines the state at which the provider is allowed to show.

View File

@ -4,8 +4,10 @@ import (
"context" "context"
"database/sql" "database/sql"
"errors" "errors"
"fmt"
"github.com/1f349/lavender/auth" "github.com/1f349/lavender/auth"
"github.com/1f349/lavender/database" "github.com/1f349/lavender/database"
"html/template"
"net/http" "net/http"
) )
@ -20,13 +22,13 @@ type BasicLogin struct {
DB basicLoginDB DB basicLoginDB
} }
func (b *BasicLogin) Factor() auth.State { return FactorBasic } 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) RenderData(ctx context.Context, req *http.Request, user *database.User, data map[string]any) error { func (b *BasicLogin) RenderTemplate(ctx context.Context, req *http.Request, user *database.User) (template.HTML, error) {
data["username"] = req.FormValue("username") // TODO(melon): rewrite this
return nil return template.HTML(fmt.Sprintf("<div>%s</div>", req.FormValue("username"))), nil
} }
func (b *BasicLogin) AttemptLogin(ctx context.Context, req *http.Request, user *database.User) error { func (b *BasicLogin) AttemptLogin(ctx context.Context, req *http.Request, user *database.User) error {
@ -39,7 +41,7 @@ func (b *BasicLogin) AttemptLogin(ctx context.Context, req *http.Request, user *
login, err := b.DB.CheckLogin(ctx, un, pw) login, err := b.DB.CheckLogin(ctx, un, pw)
switch { switch {
case err == nil: case err == nil:
return auth.lookupUser(ctx, b.DB, login.Subject, false, user) return auth.LookupUser(ctx, b.DB, login.Subject, 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

@ -9,6 +9,7 @@ import (
"github.com/1f349/lavender/issuer" "github.com/1f349/lavender/issuer"
"github.com/google/uuid" "github.com/google/uuid"
"golang.org/x/oauth2" "golang.org/x/oauth2"
"html/template"
"net/http" "net/http"
"time" "time"
) )
@ -33,13 +34,12 @@ func (o OAuthLogin) Init() {
o.flow = cache.New[string, flowStateData]() o.flow = cache.New[string, flowStateData]()
} }
func (o OAuthLogin) Factor() auth.State { return FactorBasic } 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) RenderData(ctx context.Context, req *http.Request, user *database.User, data map[string]any) error { func (o OAuthLogin) RenderTemplate(ctx context.Context, req *http.Request, user *database.User) (template.HTML, error) {
//TODO implement me return "<div>OAuth Login Template</div>", nil
panic("implement me")
} }
func (o OAuthLogin) AttemptLogin(ctx context.Context, req *http.Request, user *database.User) error { func (o OAuthLogin) AttemptLogin(ctx context.Context, req *http.Request, user *database.User) error {

View File

@ -3,9 +3,11 @@ package providers
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"github.com/1f349/lavender/auth" "github.com/1f349/lavender/auth"
"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"
) )
@ -24,28 +26,28 @@ type OtpLogin struct {
DB otpLoginDB DB otpLoginDB
} }
func (o *OtpLogin) Factor() auth.State { return FactorExtended } 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) RenderData(_ context.Context, _ *http.Request, user *database.User, data map[string]any) error { func (o *OtpLogin) RenderTemplate(_ context.Context, _ *http.Request, user *database.User) (template.HTML, error) {
if user == nil || user.Subject == "" { if user == nil || user.Subject == "" {
return ErrRequiresPreviousFactor return "", fmt.Errorf("requires previous factor")
} }
if user.OtpSecret == "" || !isDigitsSupported(user.OtpDigits) { if user.OtpSecret == "" || !isDigitsSupported(user.OtpDigits) {
return auth.ErrUserDoesNotSupportFactor return "", fmt.Errorf("user does not support factor")
} }
// no need to provide render data // no need to provide render data
return nil return "<div>OTP login template</div>", nil
} }
func (o *OtpLogin) AttemptLogin(ctx context.Context, req *http.Request, user *database.User) error { 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 ErrRequiresPreviousFactor return fmt.Errorf("requires previous factor")
} }
if user.OtpSecret == "" || !isDigitsSupported(user.OtpDigits) { if user.OtpSecret == "" || !isDigitsSupported(user.OtpDigits) {
return auth.ErrUserDoesNotSupportFactor return fmt.Errorf("user does not support factor")
} }
code := req.FormValue("code") code := req.FormValue("code")

View File

@ -2,13 +2,15 @@ package providers
import ( import (
"context" "context"
"fmt"
"github.com/1f349/lavender/auth" "github.com/1f349/lavender/auth"
"github.com/1f349/lavender/database" "github.com/1f349/lavender/database"
"html/template"
"net/http" "net/http"
) )
type passkeyLoginDB interface { type passkeyLoginDB interface {
auth.lookupUserDB auth.LookupUserDB
} }
var _ auth.Provider = (*PasskeyLogin)(nil) var _ auth.Provider = (*PasskeyLogin)(nil)
@ -17,19 +19,18 @@ type PasskeyLogin struct {
DB passkeyLoginDB DB passkeyLoginDB
} }
func (p *PasskeyLogin) Factor() auth.State { return FactorBasic } 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) RenderData(ctx context.Context, req *http.Request, user *database.User, data map[string]any) error { func (p *PasskeyLogin) RenderTemplate(ctx context.Context, req *http.Request, user *database.User) (template.HTML, error) {
if user == nil || user.Subject == "" { if user == nil || user.Subject == "" {
return ErrRequiresPreviousFactor return "", fmt.Errorf("requires previous factor")
} }
if user.OtpSecret == "" { if user.OtpSecret == "" {
return auth.ErrUserDoesNotSupportFactor return "", fmt.Errorf("user does not support factor")
} }
//TODO implement me
panic("implement me") panic("implement me")
} }
@ -41,7 +42,7 @@ func init() {
func (p *PasskeyLogin) AttemptLogin(ctx context.Context, req *http.Request, user *database.User) error { func (p *PasskeyLogin) AttemptLogin(ctx context.Context, req *http.Request, user *database.User) error {
if user.Subject == "" && !passkeyShortcut { if user.Subject == "" && !passkeyShortcut {
return ErrRequiresPreviousFactor return fmt.Errorf("requires previous factor")
} }
//TODO implement me //TODO implement me

View File

@ -22,7 +22,7 @@ func (u UserAuth) NextFlowUrl(origin *url.URL) *url.URL {
if origin.Path == "/login" || origin.Path == "/callback" { if origin.Path == "/login" || origin.Path == "/callback" {
return nil return nil
} }
if u.Factor < FactorAuthorized { if !u.Factor.IsLoggedIn() {
return PrepareRedirectUrl("/login", origin) return PrepareRedirectUrl("/login", origin)
} }
return nil return nil

View File

@ -18,7 +18,7 @@ func TestUserAuth_NextFlowUrl(t *testing.T) {
assert.Equal(t, url.URL{Path: "/login"}, *u.NextFlowUrl(&url.URL{})) assert.Equal(t, url.URL{Path: "/login"}, *u.NextFlowUrl(&url.URL{}))
assert.Equal(t, url.URL{Path: "/login", RawQuery: url.Values{"redirect": {"/hello"}}.Encode()}, *u.NextFlowUrl(&url.URL{Path: "/hello"})) assert.Equal(t, url.URL{Path: "/login", RawQuery: url.Values{"redirect": {"/hello"}}.Encode()}, *u.NextFlowUrl(&url.URL{Path: "/hello"}))
assert.Equal(t, url.URL{Path: "/login", RawQuery: url.Values{"redirect": {"/hello?a=A"}}.Encode()}, *u.NextFlowUrl(&url.URL{Path: "/hello", RawQuery: url.Values{"a": {"A"}}.Encode()})) assert.Equal(t, url.URL{Path: "/login", RawQuery: url.Values{"redirect": {"/hello?a=A"}}.Encode()}, *u.NextFlowUrl(&url.URL{Path: "/hello", RawQuery: url.Values{"a": {"A"}}.Encode()}))
u.Factor = auth.FactorAuthorized u.Factor = auth.StateExtended
assert.Nil(t, u.NextFlowUrl(&url.URL{})) assert.Nil(t, u.NextFlowUrl(&url.URL{}))
} }

View File

@ -47,10 +47,11 @@ func (h *httpServer) testAuthSources(req *http.Request, user *database.User, fac
data := make(map[string]any) data := make(map[string]any)
for _, i := range h.authSources { for _, i := range h.authSources {
// ignore not-supported factors // ignore not-supported factors
if i.State()&factor == 0 { if i.AccessState() != factor {
continue continue
} }
err := i.RenderTemplate(req.Context(), req, user, data) page, err := i.RenderTemplate(req.Context(), req, user)
_ = page
authSource[i.Name()] = err == nil authSource[i.Name()] = err == nil
clear(data) clear(data)
} }
@ -77,14 +78,14 @@ func (h *httpServer) loginGet(rw http.ResponseWriter, req *http.Request, _ httpr
return return
} }
fmt.Printf("%#v\n", h.testAuthSources(req, userPtr, auth2.FactorBasic)) fmt.Printf("%#v\n", h.testAuthSources(req, userPtr, auth2.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.FactorBasic), "Auth": h.testAuthSources(req, userPtr, auth2.StateBasic),
}) })
return return
} }
@ -95,7 +96,7 @@ func (h *httpServer) loginGet(rw http.ResponseWriter, req *http.Request, _ httpr
"LoginName": "", "LoginName": "",
"Redirect": req.URL.Query().Get("redirect"), "Redirect": req.URL.Query().Get("redirect"),
"Source": "start", "Source": "start",
"Auth": h.testAuthSources(req, nil, auth2.FactorBasic), "Auth": h.testAuthSources(req, nil, auth2.StateBasic),
}) })
} }
@ -207,7 +208,7 @@ func (h *httpServer) updateExternalUserInfo(req *http.Request, sso *issuer.WellK
}) })
return auth2.UserAuth{ return auth2.UserAuth{
Subject: userSubject, Subject: userSubject,
Factor: auth2.FactorAuthorized, Factor: auth2.StateExtended,
UserInfo: sessionData.UserInfo, UserInfo: sessionData.UserInfo,
}, err }, err
case errors.Is(err, sql.ErrNoRows): case errors.Is(err, sql.ErrNoRows):
@ -263,7 +264,7 @@ func (h *httpServer) updateExternalUserInfo(req *http.Request, sso *issuer.WellK
// TODO(melon): this feels bad // TODO(melon): this feels bad
sessionData = auth2.UserAuth{ sessionData = auth2.UserAuth{
Subject: userSubject, Subject: userSubject,
Factor: auth2.FactorAuthorized, Factor: auth2.StateExtended,
UserInfo: sessionData.UserInfo, UserInfo: sessionData.UserInfo,
} }
@ -453,7 +454,7 @@ func (h *httpServer) fetchUserInfo(sso *issuer.WellKnownOIDC, token *oauth2.Toke
return auth2.UserAuth{ return auth2.UserAuth{
Subject: subject, Subject: subject,
Factor: auth2.FactorAuthorized, Factor: auth2.StateExtended,
UserInfo: userInfoJson, UserInfo: userInfoJson,
}, nil }, nil
} }

View File

@ -1,5 +1,7 @@
--- ---
import Header from "../components/Header.astro"; import Header from "../components/Header.astro";
const {title} = Astro.props;
--- ---
<html lang="en"> <html lang="en">
@ -9,7 +11,7 @@ import Header from "../components/Header.astro";
<meta name="viewport" content="width=device-width"/> <meta name="viewport" content="width=device-width"/>
<link rel="icon" type="image/svg+xml" href="/favicon.svg"/> <link rel="icon" type="image/svg+xml" href="/favicon.svg"/>
<meta name="generator" content={Astro.generator}/> <meta name="generator" content={Astro.generator}/>
<title>[[.ServiceName]]</title> <title>[[ renderTitle "{title}" .ServiceName ]]</title>
</head> </head>
<body> <body>
<Header/> <Header/>
@ -36,6 +38,16 @@ import Header from "../components/Header.astro";
background: #13151a; background: #13151a;
} }
main {
margin: auto;
padding: 1rem;
width: 800px;
max-width: calc(100% - 2rem);
color: white;
font-size: 20px;
line-height: 1.6;
}
code { code {
font-family: Menlo, font-family: Menlo,
Monaco, Monaco,

View File

@ -1,3 +1,12 @@
---
import Layout from "../layouts/Layout.astro";
---
<Layout title="">
</Layout>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>

View File

@ -1,123 +1,21 @@
--- ---
import Layout from '../layouts/Layout.astro'; import Layout from '../layouts/Layout.astro';
import Card from '../components/Card.astro';
--- ---
<Layout title="Welcome to Astro."> <Layout title="Welcome to Astro.">
<main> <div class="center-box sm:max-w-md">
<svg <div>Logged in as: [[ .Auth.UserInfo.name ]] ([[ .Auth.Subject ]])</div>
class="astro-a" <form method="GET" action="/manage/apps" class="space-y-4 md:space-y-6">
width="495" <button type="submit" class="btn-green">Manage Applications</button>
height="623" </form>
viewBox="0 0 495 623" [[ if .IsAdmin ]]
fill="none" <form method="GET" action="/manage/users" class="space-y-4 md:space-y-6">
xmlns="http://www.w3.org/2000/svg" <button type="submit" class="btn-green">Manage Users</button>
aria-hidden="true" </form>
> [[ end ]]
<path <form method="POST" action="/logout">
fill-rule="evenodd" <input type="hidden" name="nonce" value="[[ .Nonce ]]">
clip-rule="evenodd" <button type="submit" class="btn-red">Log out</button>
d="M167.19 364.254C83.4786 364.254 0 404.819 0 404.819C0 404.819 141.781 19.4876 142.087 18.7291C146.434 7.33701 153.027 0 162.289 0H332.441C341.703 0 348.574 7.33701 352.643 18.7291C352.92 19.5022 494.716 404.819 494.716 404.819C494.716 404.819 426.67 364.254 327.525 364.254L264.41 169.408C262.047 159.985 255.147 153.581 247.358 153.581C239.569 153.581 232.669 159.985 230.306 169.408L167.19 364.254ZM160.869 530.172C160.877 530.18 160.885 530.187 160.894 530.195L160.867 530.181C160.868 530.178 160.868 530.175 160.869 530.172ZM136.218 411.348C124.476 450.467 132.698 504.458 160.869 530.172C160.997 529.696 161.125 529.242 161.248 528.804C161.502 527.907 161.737 527.073 161.917 526.233C165.446 509.895 178.754 499.52 195.577 500.01C211.969 500.487 220.67 508.765 223.202 527.254C224.141 534.12 224.23 541.131 224.319 548.105C224.328 548.834 224.337 549.563 224.347 550.291C224.563 566.098 228.657 580.707 237.264 593.914C245.413 606.426 256.108 615.943 270.749 622.478C270.593 621.952 270.463 621.508 270.35 621.126C270.045 620.086 269.872 619.499 269.685 618.911C258.909 585.935 266.668 563.266 295.344 543.933C298.254 541.971 301.187 540.041 304.12 538.112C310.591 533.854 317.059 529.599 323.279 525.007C345.88 508.329 360.09 486.327 363.431 457.844C364.805 446.148 363.781 434.657 359.848 423.275C358.176 424.287 356.587 425.295 355.042 426.275C351.744 428.366 348.647 430.33 345.382 431.934C303.466 452.507 259.152 455.053 214.03 448.245C184.802 443.834 156.584 436.019 136.218 411.348Z" </form>
fill="url(#paint0_linear_1805_24383)"></path> </div>
<defs>
<linearGradient
id="paint0_linear_1805_24383"
x1="247.358"
y1="0"
x2="247.358"
y2="622.479"
gradientUnits="userSpaceOnUse"
>
<stop stop-opacity="0.9"></stop>
<stop offset="1" stop-opacity="0.2"></stop>
</linearGradient>
</defs>
</svg>
<h1>Welcome to <span class="text-gradient">Astro</span></h1>
<p class="instructions">
To get started, open the directory <code>src/pages</code> in your project.<br />
<strong>Code Challenge:</strong> Tweak the "Welcome to Astro" message above.
</p>
<ul role="list" class="link-card-grid">
<Card
href="https://docs.astro.build/"
title="Documentation"
body="Learn how Astro works and explore the official API docs."
/>
<Card
href="https://astro.build/integrations/"
title="Integrations"
body="Supercharge your project with new frameworks and libraries."
/>
<Card
href="https://astro.build/themes/"
title="Themes"
body="Explore a galaxy of community-built starter themes."
/>
<Card
href="https://astro.build/chat/"
title="Community"
body="Come say hi to our amazing Discord community. ❤️"
/>
</ul>
</main>
</Layout> </Layout>
<style>
main {
margin: auto;
padding: 1rem;
width: 800px;
max-width: calc(100% - 2rem);
color: white;
font-size: 20px;
line-height: 1.6;
}
.astro-a {
position: absolute;
top: -32px;
left: 50%;
transform: translatex(-50%);
width: 220px;
height: auto;
z-index: -1;
}
h1 {
font-size: 4rem;
font-weight: 700;
line-height: 1;
text-align: center;
margin-bottom: 1em;
}
.text-gradient {
background-image: var(--accent-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-size: 400%;
background-position: 0%;
}
.instructions {
margin-bottom: 2rem;
border: 1px solid rgba(var(--accent-light), 25%);
background: linear-gradient(rgba(var(--accent-dark), 66%), rgba(var(--accent-dark), 33%));
padding: 1.5rem;
border-radius: 8px;
}
.instructions code {
font-size: 0.8em;
font-weight: bold;
background: rgba(var(--accent-light), 12%);
color: rgb(var(--accent-light));
border-radius: 4px;
padding: 0.3em 0.4em;
}
.instructions strong {
color: rgb(var(--accent-light));
}
.link-card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(24ch, 1fr));
gap: 2rem;
padding: 0;
}
</style>

View File

@ -11,6 +11,7 @@ import (
"io/fs" "io/fs"
"net/http" "net/http"
"os" "os"
"path"
"path/filepath" "path/filepath"
"strings" "strings"
) )
@ -45,14 +46,20 @@ func LoadPages(wd string) error {
pageTemplates, err = template.New("web").Delims("[[", "]]").Funcs(template.FuncMap{ pageTemplates, err = template.New("web").Delims("[[", "]]").Funcs(template.FuncMap{
"emailHide": utils.EmailHide, "emailHide": utils.EmailHide,
}).ParseFS(webCombinedDir, "*.html") "renderTitle":
}).ParseFS(webCombinedDir, "*/index.html")
return err return err
}) })
} }
func renderTitle(title string, service string) string {
}
func RenderPageTemplate(wr io.Writer, name string, data any) { func RenderPageTemplate(wr io.Writer, name string, data any) {
err := pageTemplates.ExecuteTemplate(wr, name+".html", data) p := path.Join(name, "index.html")
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)
} }