Files
oslstats/internal/handlers/callback.go
2026-01-24 00:58:31 +11:00

203 lines
5.3 KiB
Go

package handlers
import (
"context"
"net/http"
"time"
"git.haelnorr.com/h/golib/hws"
"github.com/pkg/errors"
"github.com/uptrace/bun"
"git.haelnorr.com/h/oslstats/internal/config"
"git.haelnorr.com/h/oslstats/internal/db"
"git.haelnorr.com/h/oslstats/internal/discord"
"git.haelnorr.com/h/oslstats/internal/store"
"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 {
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,
track.IP,
track.UserAgent,
track.Path,
track.FirstSeen.Format("2006-01-02T15:04:05Z07:00"),
)
// Clear the tracking entry
store.ClearRedirectTrack(r, "/callback")
// Show error page
throwError(
server,
w,
r,
http.StatusBadRequest,
"OAuth callback failed: Too many redirect attempts. Please try logging in again.",
err,
"warn",
)
return
}
state := r.URL.Query().Get("state")
code := r.URL.Query().Get("code")
if state == "" && code == "" {
http.Redirect(w, r, "/", http.StatusBadRequest)
return
}
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 {
case "login":
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
tx, err := conn.BeginTx(ctx, nil)
if err != nil {
throwInternalServiceError(server, w, r, "DB Transaction failed to start", err)
return
}
defer tx.Rollback()
redirect, err := login(ctx, tx, cfg, w, r, code, store, discordAPI)
if err != nil {
throwInternalServiceError(server, w, r, "OAuth login failed", err)
return
}
tx.Commit()
redirect()
return
}
},
)
}
// 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
}
func (e *verifyStateError) Error() string {
return e.err.Error()
}
func (e *verifyStateError) IsCookieError() bool {
return e.cookieError
}
func verifyState(
cfg *oauth.Config,
w http.ResponseWriter,
r *http.Request,
state string,
) (string, error) {
if r == nil {
return "", errors.New("request cannot be nil")
}
if state == "" {
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,
}
}
oauth.DeleteStateCookie(w)
return data, nil
}
func login(
ctx context.Context,
tx bun.Tx,
cfg *config.Config,
w http.ResponseWriter,
r *http.Request,
code string,
store *store.Store,
discordAPI *discord.APIClient,
) (func(), error) {
token, err := discord.AuthorizeWithCode(cfg.Discord, code, cfg.HWSAuth.TrustedHost, discordAPI)
if err != nil {
return nil, errors.Wrap(err, "discord.AuthorizeWithCode")
}
session, err := discord.NewOAuthSession(token)
if err != nil {
return nil, errors.Wrap(err, "discord.NewOAuthSession")
}
discorduser, err := session.GetUser()
if err != nil {
return nil, errors.Wrap(err, "session.GetUser")
}
user, err := db.GetUserByDiscordID(ctx, tx, discorduser.ID)
if err != nil {
return nil, errors.Wrap(err, "db.GetUserByDiscordID")
}
var redirect string
if user == nil {
sessionID, err := store.CreateRegistrationSession(discorduser, token)
if err != nil {
return nil, errors.Wrap(err, "store.CreateRegistrationSession")
}
http.SetCookie(w, &http.Cookie{
Name: "registration_session",
Path: "/",
Value: sessionID,
MaxAge: 300, // 5 minutes
HttpOnly: true,
Secure: cfg.HWSAuth.SSL,
SameSite: http.SameSiteLaxMode,
})
redirect = "/register"
} else {
// TODO: log them in
}
return func() {
http.Redirect(w, r, redirect, http.StatusSeeOther)
}, nil
}