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.") }