added logout

This commit is contained in:
2026-01-24 15:23:28 +11:00
parent 73a5c9726b
commit 4dec97def8
14 changed files with 327 additions and 191 deletions

View File

@@ -28,11 +28,9 @@ func Callback(
) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
// Track callback redirect attempts
attempts, exceeded, track := store.TrackRedirect(r, "/callback", 5)
if exceeded {
// Build detailed error for logging
err := errors.Errorf(
"callback redirect loop detected after %d attempts | ip=%s ua=%s path=%s first_seen=%s",
attempts,
@@ -42,10 +40,8 @@ func Callback(
track.FirstSeen.Format("2006-01-02T15:04:05Z07:00"),
)
// Clear the tracking entry
store.ClearRedirectTrack(r, "/callback")
// Show error page
throwError(
server,
w,
@@ -66,23 +62,17 @@ func Callback(
}
data, err := verifyState(cfg.OAuth, w, r, state)
if err != nil {
// Check if this is a cookie error (401) or signature error (403)
if vsErr, ok := err.(*verifyStateError); ok {
if vsErr.IsCookieError() {
// Cookie missing/expired - normal failed/expired session (DEBUG)
throwUnauthorized(server, w, r, "OAuth session not found or expired", err)
} else {
// Signature verification failed - security violation (WARN)
throwForbiddenSecurity(server, w, r, "OAuth state verification failed", err)
}
} else {
// Unknown error type - treat as security issue
throwForbiddenSecurity(server, w, r, "OAuth state verification failed", err)
}
return
}
// SUCCESS POINT: State verified successfully
// Clear redirect tracking - OAuth callback completed successfully
store.ClearRedirectTrack(r, "/callback")
switch data {
@@ -108,10 +98,9 @@ func Callback(
)
}
// verifyStateError wraps an error with context about what went wrong
type verifyStateError struct {
err error
cookieError bool // true if cookie missing/invalid, false if signature invalid
cookieError bool
}
func (e *verifyStateError) Error() string {
@@ -135,20 +124,16 @@ func verifyState(
return "", errors.New("state param field is empty")
}
// Try to get the cookie
uak, err := oauth.GetStateCookie(r)
if err != nil {
// Cookie missing or invalid - this is a 401 (not authenticated)
return "", &verifyStateError{
err: errors.Wrap(err, "oauth.GetStateCookie"),
cookieError: true,
}
}
// Verify the state signature
data, err := oauth.VerifyState(cfg, state, uak)
if err != nil {
// Signature verification failed - this is a 403 (security violation)
return "", &verifyStateError{
err: errors.Wrap(err, "oauth.VerifyState"),
cookieError: false,
@@ -170,9 +155,9 @@ func login(
store *store.Store,
discordAPI *discord.APIClient,
) (func(), error) {
token, err := discord.AuthorizeWithCode(cfg.Discord, code, cfg.HWSAuth.TrustedHost, discordAPI)
token, err := discordAPI.AuthorizeWithCode(code)
if err != nil {
return nil, errors.Wrap(err, "discord.AuthorizeWithCode")
return nil, errors.Wrap(err, "discordAPI.AuthorizeWithCode")
}
session, err := discord.NewOAuthSession(token)
if err != nil {
@@ -204,6 +189,10 @@ func login(
})
redirect = "/register"
} else {
err = user.UpdateDiscordToken(ctx, tx, token)
if err != nil {
return nil, errors.Wrap(err, "user.UpdateDiscordToken")
}
err := auth.Login(w, r, user, true)
if err != nil {
return nil, errors.Wrap(err, "auth.Login")

View File

@@ -0,0 +1,45 @@
package handlers
import (
"context"
"net/http"
"time"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/oslstats/internal/config"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/store"
"github.com/uptrace/bun"
)
func IsUsernameUnique(
server *hws.Server,
conn *bun.DB,
cfg *config.Config,
store *store.Store,
) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
username := r.FormValue("username")
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
tx, err := conn.BeginTx(ctx, nil)
if err != nil {
throwInternalServiceError(server, w, r, "Database transaction failed", err)
return
}
defer tx.Rollback()
unique, err := db.IsUsernameUnique(ctx, tx, username)
if err != nil {
throwInternalServiceError(server, w, r, "Database query failed", err)
return
}
tx.Commit()
if !unique {
w.WriteHeader(http.StatusConflict)
} else {
w.WriteHeader(http.StatusOK)
}
},
)
}

View File

@@ -17,11 +17,9 @@ func Login(server *hws.Server, cfg *config.Config, st *store.Store, discordAPI *
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
cookies.SetPageFrom(w, r, cfg.HWSAuth.TrustedHost)
// Track login redirect attempts
attempts, exceeded, track := st.TrackRedirect(r, "/login", 5)
if exceeded {
// Build detailed error for logging
err := errors.Errorf(
"login redirect loop detected after %d attempts | ip=%s ua=%s path=%s first_seen=%s",
attempts,
@@ -31,10 +29,8 @@ func Login(server *hws.Server, cfg *config.Config, st *store.Store, discordAPI *
track.FirstSeen.Format("2006-01-02T15:04:05Z07:00"),
)
// Clear the tracking entry
st.ClearRedirectTrack(r, "/login")
// Show error page
throwError(
server,
w,
@@ -54,14 +50,11 @@ func Login(server *hws.Server, cfg *config.Config, st *store.Store, discordAPI *
}
oauth.SetStateCookie(w, uak, cfg.HWSAuth.SSL)
link, err := discord.GetOAuthLink(cfg.Discord, state, cfg.HWSAuth.TrustedHost)
link, err := discordAPI.GetOAuthLink(state)
if err != nil {
throwInternalServiceError(server, w, r, "An error occurred trying to generate the login link", err)
return
}
// SUCCESS POINT: OAuth link generated, redirecting to Discord
// Clear redirect tracking - user successfully initiated OAuth
st.ClearRedirectTrack(r, "/login")
http.Redirect(w, r, link, http.StatusSeeOther)

View File

@@ -0,0 +1,59 @@
package handlers
import (
"context"
"net/http"
"time"
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/golib/hwsauth"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/discord"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
func Logout(
server *hws.Server,
auth *hwsauth.Authenticator[*db.User, bun.Tx],
conn *bun.DB,
discordAPI *discord.APIClient,
) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
tx, err := conn.BeginTx(ctx, nil)
if err != nil {
throwInternalServiceError(server, w, r, "Database error", errors.Wrap(err, "conn.BeginTx"))
return
}
defer tx.Rollback()
user := db.CurrentUser(r.Context())
if user == nil {
// JIC - should be impossible to get here if route is protected by LoginReq
w.Header().Set("HX-Redirect", "/")
return
}
token, err := user.DeleteDiscordTokens(ctx, tx)
if err != nil {
throwInternalServiceError(server, w, r, "Database error", errors.Wrap(err, "user.DeleteDiscordTokens"))
return
}
err = discordAPI.RevokeToken(token.Convert())
if err != nil {
throwInternalServiceError(server, w, r, "Discord API error", errors.Wrap(err, "discordAPI.RevokeToken"))
return
}
err = auth.Logout(tx, w, r)
if err != nil {
throwInternalServiceError(server, w, r, "Logout failed", err)
return
}
tx.Commit()
w.Header().Set("HX-Redirect", "/")
},
)
}

View File

@@ -102,38 +102,6 @@ func Register(
)
}
func IsUsernameUnique(
server *hws.Server,
conn *bun.DB,
cfg *config.Config,
store *store.Store,
) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
username := r.FormValue("username")
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
tx, err := conn.BeginTx(ctx, nil)
if err != nil {
throwInternalServiceError(server, w, r, "Database transaction failed", err)
return
}
defer tx.Rollback()
unique, err := db.IsUsernameUnique(ctx, tx, username)
if err != nil {
throwInternalServiceError(server, w, r, "Database query failed", err)
return
}
tx.Commit()
if !unique {
w.WriteHeader(http.StatusConflict)
} else {
w.WriteHeader(http.StatusOK)
}
},
)
}
func registerUser(
ctx context.Context,
tx bun.Tx,
@@ -151,7 +119,7 @@ func registerUser(
if err != nil {
return nil, errors.Wrap(err, "db.CreateUser")
}
err = db.UpdateDiscordToken(ctx, tx, user, details.Token)
err = user.UpdateDiscordToken(ctx, tx, details.Token)
if err != nil {
return nil, errors.Wrap(err, "db.UpdateDiscordToken")
}