finished login/registration

This commit is contained in:
2026-01-24 13:13:22 +11:00
parent df977ef50f
commit 73a5c9726b
13 changed files with 164 additions and 92 deletions

View File

@@ -30,6 +30,7 @@ func setupAuth(
beginTx, beginTx,
logger, logger,
handlers.ErrorPage, handlers.ErrorPage,
conn.DB,
) )
if err != nil { if err != nil {
return nil, errors.Wrap(err, "hwsauth.NewAuthenticator") return nil, errors.Wrap(err, "hwsauth.NewAuthenticator")

View File

@@ -31,6 +31,7 @@ func setupBun(ctx context.Context, cfg *config.Config) (conn *bun.DB, close func
func loadModels(ctx context.Context, conn *bun.DB, resetDB bool) error { func loadModels(ctx context.Context, conn *bun.DB, resetDB bool) error {
models := []any{ models := []any{
(*db.User)(nil), (*db.User)(nil),
(*db.DiscordToken)(nil),
} }
for _, model := range models { for _, model := range models {

View File

@@ -44,17 +44,30 @@ func addRoutes(
{ {
Path: "/auth/callback", Path: "/auth/callback",
Method: hws.MethodGET, Method: hws.MethodGET,
Handler: auth.LogoutReq(handlers.Callback(server, conn, cfg, store, discordAPI)), Handler: auth.LogoutReq(handlers.Callback(server, auth, conn, cfg, store, discordAPI)),
}, },
{ {
Path: "/register", Path: "/register",
Method: hws.MethodGET, Method: hws.MethodGET,
Handler: auth.LogoutReq(handlers.Register(server, conn, cfg, store)), Handler: auth.LogoutReq(handlers.Register(server, auth, conn, cfg, store)),
},
{
Path: "/register",
Method: hws.MethodPOST,
Handler: auth.LogoutReq(handlers.Register(server, auth, conn, cfg, store)),
},
}
htmxRoutes := []hws.Route{
{
Path: "/htmx/isusernameunique",
Method: hws.MethodPOST,
Handler: handlers.IsUsernameUnique(server, conn, cfg, store),
}, },
} }
// Register the routes with the server // Register the routes with the server
err := server.AddRoutes(routes...) err := server.AddRoutes(append(routes, htmxRoutes...)...)
if err != nil { if err != nil {
return errors.Wrap(err, "server.AddRoutes") return errors.Wrap(err, "server.AddRoutes")
} }

4
go.mod
View File

@@ -7,7 +7,7 @@ require (
git.haelnorr.com/h/golib/ezconf v0.1.1 git.haelnorr.com/h/golib/ezconf v0.1.1
git.haelnorr.com/h/golib/hlog v0.10.4 git.haelnorr.com/h/golib/hlog v0.10.4
git.haelnorr.com/h/golib/hws v0.3.0 git.haelnorr.com/h/golib/hws v0.3.0
git.haelnorr.com/h/golib/hwsauth v0.4.0 git.haelnorr.com/h/golib/hwsauth v0.5.0
github.com/a-h/templ v0.3.977 github.com/a-h/templ v0.3.977
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/pkg/errors v0.9.1 github.com/pkg/errors v0.9.1
@@ -22,7 +22,7 @@ require (
) )
require ( require (
git.haelnorr.com/h/golib/cookies v0.9.0 // indirect git.haelnorr.com/h/golib/cookies v0.9.0
git.haelnorr.com/h/golib/jwt v0.10.1 // indirect git.haelnorr.com/h/golib/jwt v0.10.1 // indirect
github.com/bwmarrin/discordgo v0.29.0 github.com/bwmarrin/discordgo v0.29.0
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect

4
go.sum
View File

@@ -8,8 +8,8 @@ git.haelnorr.com/h/golib/hlog v0.10.4 h1:vpCsV/OddjIYx8F48U66WxojjmhEbeLGQAOBG4V
git.haelnorr.com/h/golib/hlog v0.10.4/go.mod h1:+wJ8vecQY/JITTXKmI3JfkHiUGyMs7N6wooj2wuWZbc= git.haelnorr.com/h/golib/hlog v0.10.4/go.mod h1:+wJ8vecQY/JITTXKmI3JfkHiUGyMs7N6wooj2wuWZbc=
git.haelnorr.com/h/golib/hws v0.3.0 h1:/YGzxd3sRR3DFU6qVZxpJMKV3W2wCONqZKYUDIercCo= git.haelnorr.com/h/golib/hws v0.3.0 h1:/YGzxd3sRR3DFU6qVZxpJMKV3W2wCONqZKYUDIercCo=
git.haelnorr.com/h/golib/hws v0.3.0/go.mod h1:6ZlRKnt8YMpv5XcMXmyBGmD1/euvBo3d1azEvHJjOLo= git.haelnorr.com/h/golib/hws v0.3.0/go.mod h1:6ZlRKnt8YMpv5XcMXmyBGmD1/euvBo3d1azEvHJjOLo=
git.haelnorr.com/h/golib/hwsauth v0.4.0 h1:femjTuiaE8ye4BgC1xH1r6rC7PAhuhMmhcn1FBFZLN0= git.haelnorr.com/h/golib/hwsauth v0.5.0 h1:RAr7cdMe2aden50n7d9m5R4josZZ8ikNfWGMAEGnJbo=
git.haelnorr.com/h/golib/hwsauth v0.4.0/go.mod h1:aHY2u3b+dhoymszd/keii5HX9ZWpHU3v8gQqvTb/yKc= git.haelnorr.com/h/golib/hwsauth v0.5.0/go.mod h1:NOonrVU/lX8lzuV77eDEiTwBjn7RrzYVcSdXUJWeHmQ=
git.haelnorr.com/h/golib/jwt v0.10.1 h1:1Adxt9H3Y4fWFvFjWpvg/vSFhbgCMDMxgiE3m7KvDMI= git.haelnorr.com/h/golib/jwt v0.10.1 h1:1Adxt9H3Y4fWFvFjWpvg/vSFhbgCMDMxgiE3m7KvDMI=
git.haelnorr.com/h/golib/jwt v0.10.1/go.mod h1:fbuPrfucT9lL0faV5+Q5Gk9WFJxPlwzRPpbMQKYZok4= git.haelnorr.com/h/golib/jwt v0.10.1/go.mod h1:fbuPrfucT9lL0faV5+Q5Gk9WFJxPlwzRPpbMQKYZok4=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=

View File

@@ -5,7 +5,6 @@ import (
"time" "time"
"git.haelnorr.com/h/oslstats/internal/discord" "git.haelnorr.com/h/oslstats/internal/discord"
"github.com/bwmarrin/discordgo"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
@@ -19,10 +18,7 @@ type DiscordToken struct {
ExpiresAt int64 `bun:"expires_at,notnull"` ExpiresAt int64 `bun:"expires_at,notnull"`
} }
func UpdateDiscordToken(ctx context.Context, db *bun.DB, user *discordgo.User, token *discord.Token) error { func UpdateDiscordToken(ctx context.Context, tx bun.Tx, user *User, token *discord.Token) error {
if db == nil {
return errors.New("db cannot be nil")
}
if user == nil { if user == nil {
return errors.New("user cannot be nil") return errors.New("user cannot be nil")
} }
@@ -32,13 +28,13 @@ func UpdateDiscordToken(ctx context.Context, db *bun.DB, user *discordgo.User, t
expiresAt := time.Now().Add(time.Duration(token.ExpiresIn) * time.Second).Unix() expiresAt := time.Now().Add(time.Duration(token.ExpiresIn) * time.Second).Unix()
discordToken := &DiscordToken{ discordToken := &DiscordToken{
DiscordID: user.ID, DiscordID: user.DiscordID,
AccessToken: token.AccessToken, AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken, RefreshToken: token.RefreshToken,
ExpiresAt: expiresAt, ExpiresAt: expiresAt,
} }
_, err := db.NewInsert(). _, err := tx.NewInsert().
Model(discordToken). Model(discordToken).
On("CONFLICT (discord_id) DO UPDATE"). On("CONFLICT (discord_id) DO UPDATE").
Set("access_token = EXCLUDED.access_token"). Set("access_token = EXCLUDED.access_token").
@@ -46,5 +42,8 @@ func UpdateDiscordToken(ctx context.Context, db *bun.DB, user *discordgo.User, t
Set("expires_at = EXCLUDED.expires_at"). Set("expires_at = EXCLUDED.expires_at").
Exec(ctx) Exec(ctx)
return err if err != nil {
return errors.Wrap(err, "tx.NewInsert")
}
return nil
} }

View File

@@ -2,6 +2,7 @@ package db
import ( import (
"context" "context"
"fmt"
"time" "time"
"git.haelnorr.com/h/golib/hwsauth" "git.haelnorr.com/h/golib/hwsauth"
@@ -63,6 +64,7 @@ func CreateUser(ctx context.Context, tx bun.Tx, username string, discorduser *di
// GetUserByID queries the database for a user matching the given ID // GetUserByID queries the database for a user matching the given ID
// Returns nil, nil if no user is found // Returns nil, nil if no user is found
func GetUserByID(ctx context.Context, tx bun.Tx, id int) (*User, error) { func GetUserByID(ctx context.Context, tx bun.Tx, id int) (*User, error) {
fmt.Printf("user id requested: %v", id)
user := new(User) user := new(User)
err := tx.NewSelect(). err := tx.NewSelect().
Model(user). Model(user).

View File

@@ -5,7 +5,9 @@ import (
"net/http" "net/http"
"time" "time"
"git.haelnorr.com/h/golib/cookies"
"git.haelnorr.com/h/golib/hws" "git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/golib/hwsauth"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/uptrace/bun" "github.com/uptrace/bun"
@@ -16,7 +18,14 @@ import (
"git.haelnorr.com/h/oslstats/pkg/oauth" "git.haelnorr.com/h/oslstats/pkg/oauth"
) )
func Callback(server *hws.Server, conn *bun.DB, cfg *config.Config, store *store.Store, discordAPI *discord.APIClient) http.Handler { func Callback(
server *hws.Server,
auth *hwsauth.Authenticator[*db.User, bun.Tx],
conn *bun.DB,
cfg *config.Config,
store *store.Store,
discordAPI *discord.APIClient,
) http.Handler {
return http.HandlerFunc( return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) { func(w http.ResponseWriter, r *http.Request) {
// Track callback redirect attempts // Track callback redirect attempts
@@ -86,7 +95,7 @@ func Callback(server *hws.Server, conn *bun.DB, cfg *config.Config, store *store
return return
} }
defer tx.Rollback() defer tx.Rollback()
redirect, err := login(ctx, tx, cfg, w, r, code, store, discordAPI) redirect, err := login(ctx, auth, tx, cfg, w, r, code, store, discordAPI)
if err != nil { if err != nil {
throwInternalServiceError(server, w, r, "OAuth login failed", err) throwInternalServiceError(server, w, r, "OAuth login failed", err)
return return
@@ -152,6 +161,7 @@ func verifyState(
func login( func login(
ctx context.Context, ctx context.Context,
auth *hwsauth.Authenticator[*db.User, bun.Tx],
tx bun.Tx, tx bun.Tx,
cfg *config.Config, cfg *config.Config,
w http.ResponseWriter, w http.ResponseWriter,
@@ -194,7 +204,11 @@ func login(
}) })
redirect = "/register" redirect = "/register"
} else { } else {
// TODO: log them in err := auth.Login(w, r, user, true)
if err != nil {
return nil, errors.Wrap(err, "auth.Login")
}
redirect = cookies.CheckPageFrom(w, r)
} }
return func() { return func() {
http.Redirect(w, r, redirect, http.StatusSeeOther) http.Redirect(w, r, redirect, http.StatusSeeOther)

View File

@@ -3,6 +3,7 @@ package handlers
import ( import (
"net/http" "net/http"
"git.haelnorr.com/h/golib/cookies"
"git.haelnorr.com/h/golib/hws" "git.haelnorr.com/h/golib/hws"
"github.com/pkg/errors" "github.com/pkg/errors"
@@ -15,6 +16,7 @@ import (
func Login(server *hws.Server, cfg *config.Config, st *store.Store, discordAPI *discord.APIClient) http.Handler { func Login(server *hws.Server, cfg *config.Config, st *store.Store, discordAPI *discord.APIClient) http.Handler {
return http.HandlerFunc( return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) { func(w http.ResponseWriter, r *http.Request) {
cookies.SetPageFrom(w, r, cfg.HWSAuth.TrustedHost)
// Track login redirect attempts // Track login redirect attempts
attempts, exceeded, track := st.TrackRedirect(r, "/login", 5) attempts, exceeded, track := st.TrackRedirect(r, "/login", 5)

View File

@@ -5,7 +5,9 @@ import (
"net/http" "net/http"
"time" "time"
"git.haelnorr.com/h/golib/cookies"
"git.haelnorr.com/h/golib/hws" "git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/golib/hwsauth"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/uptrace/bun" "github.com/uptrace/bun"
@@ -17,6 +19,7 @@ import (
func Register( func Register(
server *hws.Server, server *hws.Server,
auth *hwsauth.Authenticator[*db.User, bun.Tx],
conn *bun.DB, conn *bun.DB,
cfg *config.Config, cfg *config.Config,
store *store.Store, store *store.Store,
@@ -56,7 +59,6 @@ func Register(
return return
} }
details, ok := store.GetRegistrationSession(sessionCookie.Value) details, ok := store.GetRegistrationSession(sessionCookie.Value)
ok = false
if !ok { if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther) http.Redirect(w, r, "/login", http.StatusSeeOther)
return return
@@ -73,20 +75,27 @@ func Register(
defer tx.Rollback() defer tx.Rollback()
method := r.Method method := r.Method
if method == "GET" { if method == "GET" {
unique, err := db.IsUsernameUnique(ctx, tx, details.DiscordUser.Username)
if err != nil {
throwInternalServiceError(server, w, r, "Database query failed", err)
return
}
tx.Commit() tx.Commit()
page.Register(details.DiscordUser.Username, unique).Render(r.Context(), w) page.Register(details.DiscordUser.Username).Render(r.Context(), w)
return return
} }
if method == "POST" { if method == "POST" {
// TODO: register the user username := r.FormValue("username")
user, err := registerUser(ctx, tx, username, details)
// get the form data if err != nil {
// throwInternalServiceError(server, w, r, "Registration failed", err)
}
tx.Commit()
if user == nil {
w.WriteHeader(http.StatusConflict)
} else {
err = auth.Login(w, r, user, true)
if err != nil {
throwInternalServiceError(server, w, r, "Login failed", err)
}
pageFrom := cookies.CheckPageFrom(w, r)
w.Header().Set("HX-Redirect", pageFrom)
}
return return
} }
}, },
@@ -124,3 +133,27 @@ func IsUsernameUnique(
}, },
) )
} }
func registerUser(
ctx context.Context,
tx bun.Tx,
username string,
details *store.RegistrationSession,
) (*db.User, error) {
unique, err := db.IsUsernameUnique(ctx, tx, username)
if err != nil {
return nil, errors.Wrap(err, "db.IsUsernameUnique")
}
if !unique {
return nil, nil
}
user, err := db.CreateUser(ctx, tx, username, details.DiscordUser)
if err != nil {
return nil, errors.Wrap(err, "db.CreateUser")
}
err = db.UpdateDiscordToken(ctx, tx, user, details.Token)
if err != nil {
return nil, errors.Wrap(err, "db.UpdateDiscordToken")
}
return user, nil
}

View File

@@ -1,25 +1,42 @@
package form package form
templ RegisterForm(username, registerError string) { templ RegisterForm(username string) {
{{ usernameErr := "Username is taken" }}
<form <form
hx-post="/register" hx-post="/register"
x-data={ templ.JSFuncCall( hx-swap="none"
"registerFormData", registerError, usernameErr, x-data={ templ.JSFuncCall("registerFormData").CallInline }
).CallInline } @submit="handleSubmit()"
x-on:htmx:xhr:loadstart="submitted=true;buttontext='Loading...'" @htmx:after-request="if(submitTimeout) clearTimeout(submitTimeout); if(!$event.detail.successful) { isSubmitting=false; buttontext='Register'; if($event.detail.xhr.status === 409) { errorMessage='Username is already taken'; isUnique=false; } else { errorMessage='An error occurred. Please try again.'; } }"
> >
<script> <script>
function registerFormData(err, usernameErr) { function registerFormData() {
return { return {
submitted: false, canSubmit: false,
buttontext: "Register", buttontext: "Register",
errorMessage: err, errorMessage: "",
errUsername: err === usernameErr ? true : false, isChecking: false,
isUnique: false,
isEmpty: true,
isSubmitting: false,
submitTimeout: null,
resetErr() { resetErr() {
this.errorMessage = ""; this.errorMessage = "";
this.errUsername = false; this.isChecking = false;
this.isUnique = false;
}, },
enableSubmit() {
this.canSubmit = true;
},
handleSubmit() {
this.isSubmitting = true;
this.buttontext = 'Loading...';
// Set timeout for 10 seconds
this.submitTimeout = setTimeout(() => {
this.isSubmitting = false;
this.buttontext = 'Register';
this.errorMessage = 'Request timed out. Please try again.';
}, 10000);
}
}; };
} }
</script> </script>
@@ -32,48 +49,34 @@ templ RegisterForm(username, registerError string) {
type="text" type="text"
id="username" id="username"
name="username" name="username"
class="py-3 px-4 block w-full rounded-lg text-sm x-bind:class="{
focus:border-blue focus:ring-blue bg-base 'py-3 px-4 block w-full rounded-lg text-sm bg-base disabled:opacity-50 disabled:pointer-events-none border-2 outline-none': true,
disabled:opacity-50 'border-overlay0 focus:border-blue': !isUnique && !errorMessage,
disabled:pointer-events-none" 'border-green focus:border-green': isUnique && !isChecking && !errorMessage,
'border-red focus:border-red': errorMessage && !isChecking
}"
required required
aria-describedby="username-error" aria-describedby="username-error"
value={ username } value={ username }
@input="resetErr()" @input="resetErr(); isEmpty = $el.value.trim() === ''; if(isEmpty) { errorMessage='Username is required'; isUnique=false; }"
hx-post="/htmx/isusernameunique"
hx-trigger="load delay:100ms, input changed delay:500ms"
hx-swap="none"
@htmx:before-request="if($el.value.trim() === '') { isEmpty=true; return; } isEmpty=false; isChecking=true; isUnique=false; errorMessage=''"
@htmx:after-request="isChecking=false; if($event.detail.successful) { isUnique=true; canSubmit=true; } else if($event.detail.xhr.status === 409) { errorMessage='Username is already taken'; isUnique=false; canSubmit=false; }"
/> />
<div
class="absolute inset-y-0 end-0
pointer-events-none pe-3 pt-3"
x-show="errUsername"
x-cloak
>
<svg
class="size-5 text-red"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 16 16"
aria-hidden="true"
>
<path
d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM8
4a.905.905 0 0 0-.9.995l.35 3.507a.552.552 0 0
0 1.1 0l.35-3.507A.905.905 0 0 0 8 4zm.002 6a1
1 0 1 0 0 2 1 1 0 0 0 0-2z"
></path>
</svg>
</div>
<p <p
class="text-center text-xs text-red mt-2" class="text-center text-xs text-red mt-2"
id="username-error" id="username-error"
x-show="errUsername" x-show="errorMessage"
x-cloak x-cloak
x-text="if (errUsername) return errorMessage;" x-text="errorMessage"
></p> ></p>
</div> </div>
</div> </div>
<button <button
x-bind:disabled="submitted" x-bind:disabled="isEmpty || !isUnique || isChecking || isSubmitting"
x-text="buttontext" x-text="buttontext"
type="submit" type="submit"
class="w-full py-3 px-4 inline-flex justify-center items-center class="w-full py-3 px-4 inline-flex justify-center items-center

View File

@@ -4,13 +4,7 @@ import "git.haelnorr.com/h/oslstats/internal/view/layout"
import "git.haelnorr.com/h/oslstats/internal/view/component/form" import "git.haelnorr.com/h/oslstats/internal/view/component/form"
// Returns the login page // Returns the login page
templ Register(username string, unique bool) { templ Register(username string) {
{{
err := ""
if !unique {
err = "Username is taken"
}
}}
@layout.Global("Register") { @layout.Global("Register") {
<div class="max-w-100 mx-auto px-2"> <div class="max-w-100 mx-auto px-2">
<div class="mt-7 bg-mantle border border-surface1 rounded-xl"> <div class="mt-7 bg-mantle border border-surface1 rounded-xl">
@@ -26,7 +20,7 @@ templ Register(username string, unique bool) {
</p> </p>
</div> </div>
<div class="mt-5"> <div class="mt-5">
@form.RegisterForm(username, err) @form.RegisterForm(username)
</div> </div>
</div> </div>
</div> </div>

View File

@@ -194,9 +194,6 @@
} }
} }
@layer utilities { @layer utilities {
.pointer-events-none {
pointer-events: none;
}
.visible { .visible {
visibility: visible; visibility: visible;
} }
@@ -220,9 +217,6 @@
.static { .static {
position: static; position: static;
} }
.inset-y-0 {
inset-block: calc(var(--spacing) * 0);
}
.end-0 { .end-0 {
inset-inline-end: calc(var(--spacing) * 0); inset-inline-end: calc(var(--spacing) * 0);
} }
@@ -465,6 +459,19 @@
border-style: var(--tw-border-style); border-style: var(--tw-border-style);
border-width: 1px; border-width: 1px;
} }
.border-2 {
border-style: var(--tw-border-style);
border-width: 2px;
}
.border-green {
border-color: var(--green);
}
.border-overlay0 {
border-color: var(--overlay0);
}
.border-red {
border-color: var(--red);
}
.border-surface1 { .border-surface1 {
border-color: var(--surface1); border-color: var(--surface1);
} }
@@ -534,12 +541,6 @@
.py-8 { .py-8 {
padding-block: calc(var(--spacing) * 8); padding-block: calc(var(--spacing) * 8);
} }
.pe-3 {
padding-inline-end: calc(var(--spacing) * 3);
}
.pt-3 {
padding-top: calc(var(--spacing) * 3);
}
.pb-6 { .pb-6 {
padding-bottom: calc(var(--spacing) * 6); padding-bottom: calc(var(--spacing) * 6);
} }
@@ -657,6 +658,10 @@
--tw-duration: 200ms; --tw-duration: 200ms;
transition-duration: 200ms; transition-duration: 200ms;
} }
.outline-none {
--tw-outline-style: none;
outline-style: none;
}
.select-none { .select-none {
-webkit-user-select: none; -webkit-user-select: none;
user-select: none; user-select: none;
@@ -768,9 +773,14 @@
border-color: var(--blue); border-color: var(--blue);
} }
} }
.focus\:ring-blue { .focus\:border-green {
&:focus { &:focus {
--tw-ring-color: var(--blue); border-color: var(--green);
}
}
.focus\:border-red {
&:focus {
border-color: var(--red);
} }
} }
.disabled\:pointer-events-none { .disabled\:pointer-events-none {