All checks were successful
Build & push Docker image / build (push) Successful in 2m22s
682 lines
19 KiB
Go
682 lines
19 KiB
Go
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, `<svg class="qr" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" shape-rendering="crispEdges" role="img" aria-label="QR code"><rect width="%d" height="%d" fill="#fff"/><path fill="#000" d="`, n, n, n, n)
|
|
for y := 0; y < c.Size; y++ {
|
|
for x := 0; x < c.Size; x++ {
|
|
if c.Black(x, y) {
|
|
fmt.Fprintf(&b, "M%d %dh1v1h-1z", x+4, y+4)
|
|
}
|
|
}
|
|
}
|
|
b.WriteString(`"/></svg>`)
|
|
return template.HTML(b.String())
|
|
}
|