CI / test (push) Failing after 42s
People sign up on the website and link IMVU accounts by putting a code in the account's tagline, which the hub reads from IMVU's public API (the most recent proof wins; link caps per person). IMVU's age verification or Access Pass make an account adult. T5DE's proxy signs in with a device code and keeps a WebSocket open, reporting the IMVU account in use and the community products it wears; /online shows those who opted in. Moderators get a console: who's online, people, link caps, bans, roles, and the audit log. Tests run the website and API against an embedded Postgres and a fake IMVU API. Co-Authored-By: Claude Opus 5.5 <[email protected]>
173 lines
5.3 KiB
Go
173 lines
5.3 KiB
Go
package web
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"golang.org/x/crypto/argon2"
|
|
|
|
"git.t5de.dev/t5de/t5de-hub/internal/store"
|
|
"git.t5de.dev/t5de/t5de-hub/internal/token"
|
|
)
|
|
|
|
// Passwords are hashed with Argon2id (OWASP's minimum: 19 MiB, 2 passes).
|
|
const (
|
|
argonTime = 2
|
|
argonMemory = 19 * 1024
|
|
argonThreads = 1
|
|
argonKeyLen = 32
|
|
)
|
|
|
|
func hashPassword(password string) string {
|
|
salt := make([]byte, 16)
|
|
rand.Read(salt)
|
|
key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
|
|
enc := base64.RawStdEncoding.EncodeToString
|
|
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", argonMemory, argonTime, argonThreads, enc(salt), enc(key))
|
|
}
|
|
|
|
func checkPassword(hash, password string) bool {
|
|
parts := strings.Split(hash, "$")
|
|
if len(parts) != 6 || parts[1] != "argon2id" {
|
|
return false
|
|
}
|
|
var m, t uint32
|
|
var p uint8
|
|
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil {
|
|
return false
|
|
}
|
|
salt, err1 := base64.RawStdEncoding.DecodeString(parts[4])
|
|
want, err2 := base64.RawStdEncoding.DecodeString(parts[5])
|
|
if err1 != nil || err2 != nil {
|
|
return false
|
|
}
|
|
got := argon2.IDKey([]byte(password), salt, t, m, p, uint32(len(want)))
|
|
return subtle.ConstantTimeCompare(got, want) == 1
|
|
}
|
|
|
|
// dummyHash is checked against for unknown usernames, so sign-in takes as long either way.
|
|
var dummyHash = hashPassword("not a real password")
|
|
|
|
var usernamePattern = regexp.MustCompile(`^[A-Za-z0-9_]{3,32}$`)
|
|
|
|
func (s *Server) signupForm(q *request) error {
|
|
if q.person != nil {
|
|
return q.redirect("/account", "")
|
|
}
|
|
s.render(q, "signup", nil)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) signup(q *request) error {
|
|
username := strings.TrimSpace(q.r.PostFormValue("username"))
|
|
password := q.r.PostFormValue("password")
|
|
problem := ""
|
|
switch {
|
|
case !usernamePattern.MatchString(username):
|
|
problem = "Usernames are 3 to 32 letters, digits or underscores."
|
|
case utf8.RuneCountInString(password) < 10:
|
|
problem = "Use a password of at least 10 characters."
|
|
case len(password) > 256:
|
|
problem = "That password is too long."
|
|
case password != q.r.PostFormValue("password2"):
|
|
problem = "The passwords don't match."
|
|
case !s.limiter.allow("signup:"+clientIP(q.r), 5, time.Hour):
|
|
problem = "Too many new accounts from here. Try again later."
|
|
}
|
|
if problem != "" {
|
|
q.w.WriteHeader(http.StatusBadRequest)
|
|
s.render(q, "signup", map[string]any{"Error": problem, "Username": username})
|
|
return nil
|
|
}
|
|
p, err := s.Store.CreatePerson(s.ctx(q), username, hashPassword(password))
|
|
if errors.Is(err, store.ErrTaken) {
|
|
q.w.WriteHeader(http.StatusBadRequest)
|
|
s.render(q, "signup", map[string]any{"Error": "That username is taken.", "Username": username})
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.Store.Audit(s.ctx(q), p.ID, "account.create", p.Username, nil)
|
|
if err := s.startSession(q, p.ID); err != nil {
|
|
return err
|
|
}
|
|
return q.redirect("/account", "Welcome to T5DE! Link your IMVU account to get started.")
|
|
}
|
|
|
|
func (s *Server) signinForm(q *request) error {
|
|
if q.person != nil {
|
|
return q.redirect("/account", "")
|
|
}
|
|
s.render(q, "signin", map[string]any{"Next": safeNext(q.r.URL.Query().Get("next"))})
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) signin(q *request) error {
|
|
username := strings.TrimSpace(q.r.PostFormValue("username"))
|
|
password := q.r.PostFormValue("password")
|
|
next := safeNext(q.r.PostFormValue("next"))
|
|
again := func(problem string) error {
|
|
q.w.WriteHeader(http.StatusBadRequest)
|
|
s.render(q, "signin", map[string]any{"Error": problem, "Username": username, "Next": next})
|
|
return nil
|
|
}
|
|
if !s.limiter.allow("signin:"+clientIP(q.r), 20, 15*time.Minute) || !s.limiter.allow("signin-user:"+strings.ToLower(username), 10, 15*time.Minute) {
|
|
return again("Too many tries. Wait a few minutes and try again.")
|
|
}
|
|
p, err := s.Store.PersonByUsername(s.ctx(q), username)
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
checkPassword(dummyHash, password)
|
|
return again("Wrong username or password.")
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !checkPassword(p.PasswordHash, password) {
|
|
return again("Wrong username or password.")
|
|
}
|
|
if p.Banned() {
|
|
return again("This account is banned. " + p.BanReason)
|
|
}
|
|
if err := s.startSession(q, p.ID); err != nil {
|
|
return err
|
|
}
|
|
return q.redirect(next, "")
|
|
}
|
|
|
|
// safeNext keeps redirects after signing in on the hub.
|
|
func safeNext(next string) string {
|
|
if !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") || strings.HasPrefix(next, "/\\") {
|
|
return "/account"
|
|
}
|
|
return next
|
|
}
|
|
|
|
const sessionLifetime = 30 * 24 * time.Hour
|
|
|
|
func (s *Server) startSession(q *request, personID int64) error {
|
|
tok, csrf := token.Random(32), token.Random(24)
|
|
if err := s.Store.CreateSession(s.ctx(q), tok, personID, csrf, time.Now().Add(sessionLifetime)); err != nil {
|
|
return err
|
|
}
|
|
http.SetCookie(q.w, &http.Cookie{Name: sessionCookie, Value: tok, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode,
|
|
Secure: strings.HasPrefix(s.PublicURL, "https://"), MaxAge: int(sessionLifetime.Seconds())})
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) signout(q *request) error {
|
|
if q.token != "" {
|
|
s.Store.DeleteSession(s.ctx(q), q.token)
|
|
}
|
|
http.SetCookie(q.w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
|
|
return q.redirect("/", "Signed out.")
|
|
}
|