173 lines
5.0 KiB
Go
173 lines
5.0 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"regexp"
|
|
"time"
|
|
|
|
"messenger/internal/auth"
|
|
"messenger/internal/store"
|
|
)
|
|
|
|
var usernameRe = regexp.MustCompile(`^[a-z0-9_]{3,32}$`)
|
|
|
|
type userJSON struct {
|
|
ID int64 `json:"id"`
|
|
Username string `json:"username"`
|
|
DisplayName string `json:"display_name"`
|
|
Avatar string `json:"avatar"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
func toUserJSON(u *store.User) userJSON {
|
|
return userJSON{
|
|
ID: u.ID,
|
|
Username: u.Username,
|
|
DisplayName: u.DisplayName,
|
|
Avatar: u.Avatar,
|
|
CreatedAt: u.CreatedAt,
|
|
}
|
|
}
|
|
|
|
type credentialsRequest struct {
|
|
Username string `json:"username"`
|
|
DisplayName string `json:"display_name"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
func (h *apiHandler) register(w http.ResponseWriter, r *http.Request) {
|
|
var req credentialsRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid json")
|
|
return
|
|
}
|
|
if !usernameRe.MatchString(req.Username) {
|
|
writeError(w, http.StatusBadRequest, "username must be 3-32 chars of [a-z0-9_]")
|
|
return
|
|
}
|
|
if req.DisplayName == "" || len(req.DisplayName) > 64 {
|
|
writeError(w, http.StatusBadRequest, "display_name must be 1-64 chars")
|
|
return
|
|
}
|
|
if len(req.Password) < 8 || len(req.Password) > 128 {
|
|
writeError(w, http.StatusBadRequest, "password must be 8-128 chars")
|
|
return
|
|
}
|
|
|
|
hash, err := auth.HashPassword(req.Password)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
user, err := h.store.CreateUser(r.Context(), req.Username, req.DisplayName, hash)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrDuplicate) {
|
|
writeError(w, http.StatusConflict, "username already taken")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
h.issueSession(w, r, user.ID)
|
|
writeJSON(w, http.StatusCreated, toUserJSON(user))
|
|
}
|
|
|
|
func (h *apiHandler) login(w http.ResponseWriter, r *http.Request) {
|
|
var req credentialsRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid json")
|
|
return
|
|
}
|
|
user, err := h.store.GetUserByUsername(r.Context(), req.Username)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
writeError(w, http.StatusUnauthorized, "invalid credentials")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
ok, err := auth.VerifyPassword(user.PasswordHash, req.Password)
|
|
if err != nil || !ok {
|
|
writeError(w, http.StatusUnauthorized, "invalid credentials")
|
|
return
|
|
}
|
|
h.issueSession(w, r, user.ID)
|
|
writeJSON(w, http.StatusOK, toUserJSON(user))
|
|
}
|
|
|
|
func (h *apiHandler) refresh(w http.ResponseWriter, r *http.Request) {
|
|
cookie, err := r.Cookie(refreshCookieName)
|
|
if err != nil {
|
|
writeError(w, http.StatusUnauthorized, "missing refresh token")
|
|
return
|
|
}
|
|
session, err := h.store.GetSessionByRefreshHash(r.Context(), auth.HashRefreshToken(cookie.Value))
|
|
if err != nil {
|
|
writeError(w, http.StatusUnauthorized, "invalid refresh token")
|
|
return
|
|
}
|
|
if session.RevokedAt != nil {
|
|
writeError(w, http.StatusUnauthorized, "session revoked")
|
|
return
|
|
}
|
|
if time.Now().After(session.ExpiresAt) {
|
|
writeError(w, http.StatusUnauthorized, "session expired")
|
|
return
|
|
}
|
|
|
|
// Rotation: revoke the used session and issue a fresh pair.
|
|
if err := h.store.RevokeSession(r.Context(), session.ID); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
h.issueSession(w, r, session.UserID)
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func (h *apiHandler) logout(w http.ResponseWriter, r *http.Request) {
|
|
if cookie, err := r.Cookie(refreshCookieName); err == nil {
|
|
session, err := h.store.GetSessionByRefreshHash(r.Context(), auth.HashRefreshToken(cookie.Value))
|
|
if err == nil {
|
|
_ = h.store.RevokeSession(r.Context(), session.ID)
|
|
}
|
|
}
|
|
clearAuthCookies(w)
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func (h *apiHandler) me(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := userIDFrom(r.Context())
|
|
if !ok {
|
|
writeError(w, http.StatusUnauthorized, "unauthenticated")
|
|
return
|
|
}
|
|
user, err := h.store.GetUserByID(r.Context(), userID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, toUserJSON(user))
|
|
}
|
|
|
|
func (h *apiHandler) issueSession(w http.ResponseWriter, r *http.Request, userID int64) {
|
|
access, err := h.tokens.IssueAccessToken(userID, accessTTL)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
rawRefresh, refreshHash, err := auth.GenerateRefreshToken()
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
_, err = h.store.CreateSession(r.Context(), userID, refreshHash, r.UserAgent(), r.RemoteAddr, time.Now().Add(refreshTTL))
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
setAuthCookies(w, access, rawRefresh, h.secure)
|
|
}
|