package sandbar import ( "context" "database/sql" "errors" "net" "net/http" "strings" "time" "golang.org/x/crypto/bcrypt" ) type User struct { ID string `json:"id"` Name string `json:"name"` Email string `json:"email"` Role string `json:"role"` // admin or member Phone string `json:"phone"` Created int64 `json:"created"` Topic string `json:"topic"` // the personal topic their phone subscribes to hash string } const ( sessionCookie = "sandbar_session" sessionTTL = 14 * 24 * time.Hour ) type ctxKey struct{} func (s *Server) countUsers() int { var n int s.st.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n) return n } func scanUser(r scanner) (*User, error) { var u User err := r.Scan(&u.ID, &u.Name, &u.Email, &u.hash, &u.Role, &u.Phone, &u.Created) if errors.Is(err, sql.ErrNoRows) { return nil, errNotFound } u.Topic = userTopic(u.ID) return &u, err } const userCols = `id,name,email,hash,role,phone,created` func (s *Server) userByID(id string) (*User, error) { return scanUser(s.st.db.QueryRow(`SELECT `+userCols+` FROM users WHERE id=?`, id)) } func (s *Server) userByEmail(email string) (*User, error) { return scanUser(s.st.db.QueryRow(`SELECT `+userCols+` FROM users WHERE email=?`, strings.TrimSpace(email))) } func (s *Server) listUsers() []User { out := []User{} rows, err := s.st.db.Query(`SELECT ` + userCols + ` FROM users ORDER BY name`) if err != nil { return out } defer rows.Close() for rows.Next() { if u, err := scanUser(rows); err == nil { out = append(out, *u) } } return out } func validPassword(p string) error { if len(p) < 10 { return errors.New("use a password of at least 10 characters") } if len(p) > 72 { return errors.New("passwords are limited to 72 characters") } return nil } func (s *Server) createUser(name, email, password, role, phone string) (*User, error) { name, email = strings.TrimSpace(name), strings.TrimSpace(email) if name == "" || !strings.Contains(email, "@") || strings.ContainsAny(email, " \r\n<>") { return nil, errors.New("a user needs a name and an email address") } if role != "admin" && role != "member" { return nil, errors.New("role must be admin or member") } if err := validPassword(password); err != nil { return nil, err } h, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return nil, err } u := &User{ID: newID(), Name: name, Email: email, Role: role, Phone: strings.TrimSpace(phone), Created: s.now().Unix()} u.Topic = userTopic(u.ID) _, err = s.st.db.Exec(`INSERT INTO users(`+userCols+`) VALUES(?,?,?,?,?,?,?)`, u.ID, u.Name, u.Email, string(h), u.Role, u.Phone, u.Created) if err != nil { if strings.Contains(err.Error(), "UNIQUE") { return nil, errors.New("that email address is already registered") } return nil, err } return u, nil } func (s *Server) setPassword(id, password string) error { if err := validPassword(password); err != nil { return err } h, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return err } _, err = s.st.db.Exec(`UPDATE users SET hash=? WHERE id=?`, string(h), id) if err == nil { s.st.db.Exec(`DELETE FROM sessions WHERE user_id=?`, id) // a password change signs everyone out } return err } // throttled limits password guessing per client address: 10 tries per 10 minutes. func (s *Server) throttled(ip string) bool { s.loginMu.Lock() defer s.loginMu.Unlock() cut := s.now().Add(-10 * time.Minute) var keep []time.Time for _, t := range s.loginTry[ip] { if t.After(cut) { keep = append(keep, t) } } s.loginTry[ip] = keep return len(keep) >= 10 } func (s *Server) noteFailure(ip string) { s.loginMu.Lock() s.loginTry[ip] = append(s.loginTry[ip], s.now()) s.loginMu.Unlock() } func clientIP(r *http.Request) string { h, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { return r.RemoteAddr } return h } var errThrottled = errors.New("too many attempts, try again in a few minutes") // login checks credentials and starts a session. func (s *Server) login(w http.ResponseWriter, r *http.Request, email, password string) (*User, error) { ip := clientIP(r) if s.throttled(ip) { return nil, errThrottled } u, err := s.userByEmail(email) hash := "$2a$10$7EqJtq98hPqEX7fNZaFWoOhi5BWYKTaYBwxwEGLMzRVr4qhCXWM9S" // so a wrong email costs the same time as a wrong password if err == nil { hash = u.hash } if bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil || err != nil { s.noteFailure(ip) return nil, errors.New("wrong email or password") } tok := newToken() exp := s.now().Add(sessionTTL) if _, err := s.st.db.Exec(`INSERT INTO sessions(hash,user_id,expires) VALUES(?,?,?)`, hashToken(tok), u.ID, exp.Unix()); err != nil { return nil, err } http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: tok, Path: "/", Expires: exp, HttpOnly: true, SameSite: http.SameSiteStrictMode, Secure: strings.HasPrefix(s.opts.URL, "https://")}) return u, nil } func (s *Server) logout(w http.ResponseWriter, r *http.Request) { if c, err := r.Cookie(sessionCookie); err == nil { s.st.db.Exec(`DELETE FROM sessions WHERE hash=?`, hashToken(c.Value)) } http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true}) } // authenticate finds who is calling: a session cookie, or an API key given as a // bearer token or as the password of HTTP basic auth (ntfy clients do the latter). func (s *Server) authenticate(r *http.Request) *User { if c, err := r.Cookie(sessionCookie); err == nil { var uid string var exp int64 if s.st.db.QueryRow(`SELECT user_id,expires FROM sessions WHERE hash=?`, hashToken(c.Value)).Scan(&uid, &exp) == nil && exp > s.now().Unix() { if u, err := s.userByID(uid); err == nil { return u } } } key := "" if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") { key = strings.TrimSpace(h[7:]) } else if _, pw, ok := r.BasicAuth(); ok { key = pw } if key == "" { return nil } var uid string if s.st.db.QueryRow(`SELECT user_id FROM apikeys WHERE hash=?`, hashToken(key)).Scan(&uid) == nil { if u, err := s.userByID(uid); err == nil { return u } } return nil } func userFrom(ctx context.Context) *User { u, _ := ctx.Value(ctxKey{}).(*User) return u } func (s *Server) createAPIKey(userID, name string) (id, token string, err error) { token = "sb_" + newToken() id = newID() _, err = s.st.db.Exec(`INSERT INTO apikeys(id,name,hash,user_id,created) VALUES(?,?,?,?,?)`, id, strings.TrimSpace(name), hashToken(token), userID, s.now().Unix()) return }