package server import ( "crypto/aes" "crypto/cipher" "crypto/hmac" "crypto/rand" "crypto/sha1" "crypto/sha256" "crypto/subtle" "encoding/base32" "encoding/base64" "encoding/binary" "encoding/hex" "errors" "fmt" "html/template" "log" "net/http" "net/url" "os" "strconv" "strings" "time" "misaki/db" "golang.org/x/crypto/bcrypt" "rsc.io/qr" ) const ( pendingCookie = "login_code" pendingTTL = 5 * time.Minute pendingTries = 5 setupTTL = 15 * time.Minute resetTTL = 30 * time.Minute totpPeriod = 30 recoveryCount = 10 ) var b32 = base32.StdEncoding.WithPadding(base32.NoPadding) type pendingLogin struct { uid int64 next string exp time.Time tries int } // loadTOTPKey reads (or creates on first run) the 32-byte key that seals TOTP secrets at rest. func loadTOTPKey(path string) (cipher.AEAD, error) { b, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { k := make([]byte, 32) if _, err := rand.Read(k); err != nil { return nil, err } f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) if err != nil { return nil, err } if _, err := f.WriteString(hex.EncodeToString(k) + "\n"); err != nil { f.Close() return nil, err } if err := f.Close(); err != nil { return nil, err } b = []byte(hex.EncodeToString(k)) } else if err != nil { return nil, err } k, err := hex.DecodeString(strings.TrimSpace(string(b))) if err != nil || len(k) != 32 { return nil, fmt.Errorf("%s: want 64 hex chars", path) } blk, err := aes.NewCipher(k) if err != nil { return nil, err } return cipher.NewGCM(blk) } func (s *Server) seal(plain []byte, aad string) string { nonce := make([]byte, s.totp.NonceSize()) if _, err := rand.Read(nonce); err != nil { panic(err) } return base64.RawStdEncoding.EncodeToString(s.totp.Seal(nonce, nonce, plain, []byte(aad))) } func (s *Server) unseal(sealed, aad string) ([]byte, error) { b, err := base64.RawStdEncoding.DecodeString(sealed) if err != nil || len(b) < s.totp.NonceSize() { return nil, errors.New("bad sealed value") } n := s.totp.NonceSize() return s.totp.Open(nil, b[:n], b[n:], []byte(aad)) } func totpAAD(uid int64) string { return "misaki-totp:" + strconv.FormatInt(uid, 10) } func setupAAD(uid int64) string { return "misaki-totp-setup:" + strconv.FormatInt(uid, 10) } // hotp is RFC 4226 dynamic truncation; callers take it mod 10^digits. func hotp(key []byte, counter uint64) uint32 { var msg [8]byte binary.BigEndian.PutUint64(msg[:], counter) m := hmac.New(sha1.New, key) m.Write(msg[:]) sum := m.Sum(nil) off := sum[len(sum)-1] & 0xf return binary.BigEndian.Uint32(sum[off:off+4]) & 0x7fffffff } func totpCode(key []byte, step int64) string { return fmt.Sprintf("%06d", hotp(key, uint64(step))%1000000) } // matchStep returns the time step (now ±1) that code belongs to, or -1. func matchStep(key []byte, code string) int64 { now := time.Now().Unix() / totpPeriod found := int64(-1) for d := int64(-1); d <= 1; d++ { if subtle.ConstantTimeCompare([]byte(totpCode(key, now+d)), []byte(code)) == 1 { found = now + d } } return found } func normCode(c string) string { return strings.Map(func(r rune) rune { if r == ' ' || r == '-' { return -1 } return r }, strings.ToLower(strings.TrimSpace(c))) } func isDigits(s string) bool { for _, r := range s { if r < '0' || r > '9' { return false } } return s != "" } func sha256Hex(s string) string { sum := sha256.Sum256([]byte(s)) return hex.EncodeToString(sum[:]) } func (s *Server) totpOn(uid int64) bool { sealed, _, err := s.db.GetTOTP(uid) return err == nil && sealed != "" } // verifyCode accepts a current TOTP code (once per step) or an unused recovery code. func (s *Server) verifyCode(uid int64, code string) bool { c := normCode(code) sealed, _, err := s.db.GetTOTP(uid) if err != nil || sealed == "" || s.totp == nil { return false } switch { case len(c) == 6 && isDigits(c): key, err := s.unseal(sealed, totpAAD(uid)) if err != nil { log.Printf("totp unseal uid=%d: %v", uid, err) return false } step := matchStep(key, c) if step < 0 { return false } ok, err := s.db.UseTOTPStep(uid, step) return err == nil && ok case len(c) == 16: ok, err := s.db.UseRecoveryCode(uid, sha256Hex(c)) return err == nil && ok } return false } // checkCode is verifyCode behind a per-user limit; the owner gets a mail when it trips. func (s *Server) checkCode(u *db.User, code string) (ok, blocked bool) { key := strconv.FormatInt(u.ID, 10) if s.codeLimiter.blocked(key) { return false, true } if s.verifyCode(u.ID, code) { return true, false } s.codeLimiter.record(key) if s.codeLimiter.blocked(key) { s.notice(u, "wrong 2FA codes", "Someone entered your misaki password correctly, then several wrong 2FA codes. Code entry is paused for an hour.\n\nIf that wasn't you, change your password now.") } return false, false } func newRecoveryCodes() (shown, hashes []string) { for range recoveryCount { b := make([]byte, 10) if _, err := rand.Read(b); err != nil { panic(err) } c := strings.ToLower(b32.EncodeToString(b)) shown = append(shown, c[0:4]+"-"+c[4:8]+"-"+c[8:12]+"-"+c[12:16]) hashes = append(hashes, sha256Hex(c)) } return } // notice mails the account owner about a security change, if they have an email on file. func (s *Server) notice(u *db.User, subject, body string) { go func() { email, err := s.db.GetUserEmail(u.ID) if err != nil || email == "" { return } msg := fmt.Sprintf("Hi %s,\n\n%s\n\nIf this wasn't you, contact an admin right away.\n\n-- misaki", u.Username, body) if err := s.smtp.send(email, "[misaki] "+subject, msg); err != nil { log.Printf("notice %s: %v", subject, err) } }() } func noStore(w http.ResponseWriter) { w.Header().Set("Cache-Control", "no-store") w.Header().Set("Referrer-Policy", "no-referrer") } func validNewPassword(p, confirm string) string { switch { case len(p) < 8: return "password must be at least 8 characters" case len(p) > 128: return "password too long" case p != confirm: return "passwords do not match" } return "" } func back(w http.ResponseWriter, r *http.Request, path, key, msg string) { http.Redirect(w, r, path+sep(path)+key+"="+url.QueryEscape(msg), http.StatusSeeOther) } // --- login code step --- func (s *Server) startPending(w http.ResponseWriter, uid int64, next string) { tok, err := randomToken() if err != nil { panic(err) } now := time.Now() s.pendingMu.Lock() for k, p := range s.pending { if now.After(p.exp) { delete(s.pending, k) } } s.pending[hashTok(tok)] = &pendingLogin{uid: uid, next: next, exp: now.Add(pendingTTL)} s.pendingMu.Unlock() http.SetCookie(w, &http.Cookie{ Name: pendingCookie, Value: tok, Path: "/login", MaxAge: int(pendingTTL.Seconds()), HttpOnly: true, Secure: s.secureCookies, SameSite: http.SameSiteStrictMode, }) } func (s *Server) getPending(r *http.Request) (string, *pendingLogin) { c, err := r.Cookie(pendingCookie) if err != nil { return "", nil } k := hashTok(c.Value) s.pendingMu.Lock() defer s.pendingMu.Unlock() p := s.pending[k] if p == nil || time.Now().After(p.exp) { delete(s.pending, k) return "", nil } return k, p } func (s *Server) dropPending(w http.ResponseWriter, k string) { s.pendingMu.Lock() delete(s.pending, k) s.pendingMu.Unlock() http.SetCookie(w, &http.Cookie{Name: pendingCookie, Path: "/login", MaxAge: -1}) } func (s *Server) handleCodeGet(w http.ResponseWriter, r *http.Request) { if _, p := s.getPending(r); p == nil { http.Redirect(w, r, "/login", http.StatusSeeOther) return } noStore(w) s.render(w, "code.html", map[string]any{"Error": r.URL.Query().Get("err")}) } func (s *Server) handleCodePost(w http.ResponseWriter, r *http.Request) { k, p := s.getPending(r) if p == nil { back(w, r, "/login", "err", "login expired, start again") return } u, err := s.db.GetUserByID(p.uid) if err != nil || u.Status != "approved" { s.dropPending(w, k) back(w, r, "/login", "err", "login expired, start again") return } ok, blocked := s.checkCode(u, r.FormValue("code")) if blocked { s.dropPending(w, k) back(w, r, "/login", "err", "too many wrong codes, try again in an hour") return } if !ok { s.pendingMu.Lock() p.tries++ tries := p.tries s.pendingMu.Unlock() if tries >= pendingTries { s.dropPending(w, k) back(w, r, "/login", "err", "too many wrong codes, start again") return } back(w, r, "/login/code", "err", "wrong code") return } s.dropPending(w, k) if err := s.startSession(w, u.ID, "pwd otp"); err != nil { s.internalError(w) return } s.redirectNext(w, r, p.next) } // --- dashboard: 2FA + password --- func (s *Server) sessionToken(r *http.Request) string { if c, err := r.Cookie(sessionCookie); err == nil { return c.Value } return "" } func (s *Server) renderSetup(w http.ResponseWriter, u *db.User, key []byte, blob, errMsg string) { secret := b32.EncodeToString(key) var grouped []string for i := 0; i < len(secret); i += 4 { grouped = append(grouped, secret[i:min(i+4, len(secret))]) } link := "otpauth://totp/" + url.PathEscape("misaki:"+u.Username) + "?" + url.Values{ "secret": {secret}, "issuer": {"misaki"}, "algorithm": {"SHA1"}, "digits": {"6"}, "period": {"30"}, }.Encode() noStore(w) s.render(w, "security.html", map[string]any{ "Mode": "setup", "Secret": strings.Join(grouped, " "), "Link": template.URL(link), "QR": qrSVG(link), "Blob": blob, "Error": errMsg, }) } func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) { u := s.sessionUser(r) if s.totp == nil { back(w, r, "/dashboard", "err", "2FA is not available") return } if s.totpOn(u.ID) { back(w, r, "/dashboard", "err", "2FA is already on") return } key := make([]byte, 20) if _, err := rand.Read(key); err != nil { s.internalError(w) return } exp := strconv.FormatInt(time.Now().Add(setupTTL).Unix(), 10) s.renderSetup(w, u, key, s.seal(append([]byte(exp+"|"), key...), setupAAD(u.ID)), "") } func (s *Server) handleTOTPEnable(w http.ResponseWriter, r *http.Request) { u := s.sessionUser(r) if s.totp == nil || s.totpOn(u.ID) { http.Redirect(w, r, "/dashboard", http.StatusSeeOther) return } blob := r.FormValue("blob") plain, err := s.unseal(blob, setupAAD(u.ID)) exp, key, found := strings.Cut(string(plain), "|") expAt, _ := strconv.ParseInt(exp, 10, 64) if err != nil || !found || time.Now().Unix() > expAt { back(w, r, "/dashboard", "err", "2FA setup expired, start again") return } step := matchStep([]byte(key), normCode(r.FormValue("code"))) if step < 0 { s.renderSetup(w, u, []byte(key), blob, "wrong code, check your phone's clock and try again") return } shown, hashes := newRecoveryCodes() if err := s.db.EnableTOTP(u.ID, s.seal([]byte(key), totpAAD(u.ID)), step, hashes); err != nil { s.internalError(w) return } _ = s.db.DeleteUserSessions(u.ID, s.sessionToken(r)) s.notice(u, "2FA turned on", "Two-factor login was turned on for your misaki account, and your other sessions were logged out.") noStore(w) s.render(w, "security.html", map[string]any{"Mode": "codes", "Codes": shown}) } func (s *Server) handleTOTPDisable(w http.ResponseWriter, r *http.Request) { u := s.sessionUser(r) ok, blocked := s.checkCode(u, r.FormValue("code")) if blocked { back(w, r, "/dashboard", "err", "too many wrong codes, try again in an hour") return } if !ok { back(w, r, "/dashboard", "err", "wrong code") return } if err := s.db.DisableTOTP(u.ID); err != nil { s.internalError(w) return } s.notice(u, "2FA turned off", "Two-factor login was turned off for your misaki account.") back(w, r, "/dashboard", "msg", "2FA turned off") } func (s *Server) handlePasswordChange(w http.ResponseWriter, r *http.Request) { u := s.sessionUser(r) if !s.loginLimiter.allow(clientIP(r)) { back(w, r, "/dashboard", "err", "too many attempts, please try again later") return } if bcrypt.CompareHashAndPassword([]byte(u.PassHash), []byte(r.FormValue("current"))) != nil { back(w, r, "/dashboard", "err", "current password is wrong") return } if msg := validNewPassword(r.FormValue("password"), r.FormValue("password_confirm")); msg != "" { back(w, r, "/dashboard", "err", msg) return } if s.totpOn(u.ID) { ok, blocked := s.checkCode(u, r.FormValue("code")) if blocked { back(w, r, "/dashboard", "err", "too many wrong codes, try again in an hour") return } if !ok { back(w, r, "/dashboard", "err", "wrong code") return } } if err := s.setPassword(u.ID, r.FormValue("password"), s.sessionToken(r)); err != nil { s.internalError(w) return } s.notice(u, "password changed", "Your misaki password was changed from the dashboard, and your other sessions were logged out.") back(w, r, "/dashboard", "msg", "password changed, other sessions logged out") } const renameCooldown = 30 * 24 * time.Hour func usernameProblem(name string) string { switch { case len(name) < 1 || len(name) > 21: return "username must be 1-21 characters" case !validUsernameRe.MatchString(name): return "username may only contain letters, numbers, hyphens and underscores" case reservedNames[strings.ToLower(name)]: return "that username is not available" } return "" } // handleRename lets a member pick a new username. The old one stays reserved // to them, so nobody can take over an identity tied to it on other services. func (s *Server) handleRename(w http.ResponseWriter, r *http.Request) { u := s.sessionUser(r) name := strings.TrimSpace(r.FormValue("username")) if !s.loginLimiter.allow(clientIP(r)) { back(w, r, "/dashboard", "err", "too many attempts, please try again later") return } if bcrypt.CompareHashAndPassword([]byte(u.PassHash), []byte(r.FormValue("current"))) != nil { back(w, r, "/dashboard", "err", "current password is wrong") return } if s.totpOn(u.ID) { ok, blocked := s.checkCode(u, r.FormValue("code")) if blocked { back(w, r, "/dashboard", "err", "too many wrong codes, try again in an hour") return } if !ok { back(w, r, "/dashboard", "err", "wrong code") return } } if name == u.Username { back(w, r, "/dashboard", "err", "that is already your username") return } if msg := usernameProblem(name); msg != "" { back(w, r, "/dashboard", "err", msg) return } last, err := s.db.LastRename(u.ID) if err != nil { s.internalError(w) return } if wait := time.Until(last.Add(renameCooldown)); wait > 0 { back(w, r, "/dashboard", "err", fmt.Sprintf("you can change your username again in %d days", int(wait.Hours()/24)+1)) return } if err := s.db.RenameUser(u.ID, name); err != nil { if errors.Is(err, db.ErrNameTaken) { back(w, r, "/dashboard", "err", "that username is taken") return } s.internalError(w) return } s.notice(u, "username changed", fmt.Sprintf("Your misaki username was changed from %s to %s.", u.Username, name)) var granted []string if svcs, err := s.db.ListUserServices(u.ID); err == nil { for _, sv := range svcs { if sv.Granted { granted = append(granted, sv.Service) } } } go func() { body := fmt.Sprintf("%s is now %s (user id %d).\nGranted services that may need migrating: %s\n", u.Username, name, u.ID, strings.Join(granted, ", ")) if err := s.smtp.notify("[misaki] Username change: "+u.Username+" -> "+name, body); err != nil { log.Printf("notify rename: %v", err) } }() back(w, r, "/dashboard", "msg", "username changed to "+name) } // setPassword stores a new password and logs out every session but keep ("" = all). func (s *Server) setPassword(uid int64, password, keep string) error { hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) if err != nil { return err } if err := s.db.SetUserPassword(uid, string(hash)); err != nil { return err } if err := s.db.DeleteResetTokens(uid); err != nil { return err } return s.db.DeleteUserSessions(uid, keep) } // --- forgot password --- func (s *Server) canReset() bool { return s.publicURL != "" && s.smtp.Host != "" } func (s *Server) handleResetGet(w http.ResponseWriter, r *http.Request) { noStore(w) tok := r.URL.Query().Get("t") if tok == "" { s.render(w, "reset.html", map[string]any{"Mode": "request", "CanReset": s.canReset()}) return } uid, err := s.db.GetResetToken(tok) if err != nil { s.render(w, "reset.html", map[string]any{"Mode": "expired"}) return } s.render(w, "reset.html", map[string]any{ "Mode": "set", "Token": tok, "NeedCode": s.totpOn(uid), "Error": r.URL.Query().Get("err"), }) } func (s *Server) handleResetPost(w http.ResponseWriter, r *http.Request) { username := strings.TrimSpace(r.FormValue("username")) if s.canReset() && username != "" && len(username) <= 64 && s.resetLimiter.allow("ip:"+clientIP(r)) && s.resetLimiter.allow("u:"+strings.ToLower(username)) { go s.sendReset(username) } noStore(w) s.render(w, "reset.html", map[string]any{"Mode": "sent"}) } func (s *Server) sendReset(username string) { u, err := s.db.GetUserByUsername(username) if err != nil || u.Status != "approved" { return } email, err := s.db.GetUserEmail(u.ID) if err != nil || email == "" { return } tok, err := s.db.CreateResetToken(u.ID, resetTTL) if err != nil { log.Printf("reset token: %v", err) return } body := fmt.Sprintf("Hi %s,\n\nSomeone (hopefully you) asked to reset your misaki password. This link works once, for 30 minutes:\n\n%s/reset?t=%s\n\nIf you didn't ask, ignore this mail; nothing changes.\n\n-- misaki", u.Username, s.publicURL, tok) if err := s.smtp.send(email, "[misaki] password reset", body); err != nil { log.Printf("reset mail: %v", err) } } func (s *Server) handleResetConfirm(w http.ResponseWriter, r *http.Request) { tok := r.FormValue("t") retry := "/reset?t=" + url.QueryEscape(tok) if !s.loginLimiter.allow(clientIP(r)) { back(w, r, retry, "err", "too many attempts, please try again later") return } uid, err := s.db.GetResetToken(tok) if err != nil { http.Redirect(w, r, "/reset?t=expired", http.StatusSeeOther) return } u, err := s.db.GetUserByID(uid) if err != nil { s.internalError(w) return } if msg := validNewPassword(r.FormValue("password"), r.FormValue("password_confirm")); msg != "" { back(w, r, retry, "err", msg) return } if s.totpOn(uid) { ok, blocked := s.checkCode(u, r.FormValue("code")) if blocked { back(w, r, retry, "err", "too many wrong codes, try again in an hour") return } if !ok { back(w, r, retry, "err", "wrong code") return } } if ok, err := s.db.ConsumeResetToken(tok); err != nil || !ok { http.Redirect(w, r, "/reset?t=expired", http.StatusSeeOther) return } if err := s.setPassword(uid, r.FormValue("password"), ""); err != nil { s.internalError(w) return } s.notice(u, "password reset", "Your misaki password was reset with a reset link, and all sessions were logged out.") back(w, r, "/login", "msg", "password changed, log in with the new one") } // qrSVG renders text as an inline SVG QR code, so the secret never leaves the server. func qrSVG(text string) template.HTML { c, err := qr.Encode(text, qr.M) if err != nil { return "" } var b strings.Builder n := c.Size + 8 fmt.Fprintf(&b, ``) return template.HTML(b.String()) }