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 }