343 lines
9.4 KiB
Go

package service
import (
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"time"
"git.umbrella.haus/ae/notatest/pkg/data"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
"github.com/rs/zerolog/log"
"golang.org/x/crypto/bcrypt"
)
type userCtxKey struct{}
// Stripped object that only contains non-critical data
type userResponse struct {
ID uuid.UUID `json:"id"`
Username string `json:"username"`
CreatedAt *time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at"`
IsAdmin bool `json:"is_admin"`
}
// Mockable database operations interface
type UserStore interface {
CreateUser(ctx context.Context, arg data.CreateUserParams) (data.User, error)
ListUsers(ctx context.Context) ([]data.User, error)
GetUserByID(ctx context.Context, id uuid.UUID) (data.User, error)
GetUserByUsername(ctx context.Context, username string) (data.User, error)
UpdatePassword(ctx context.Context, arg data.UpdatePasswordParams) error
DeleteUser(ctx context.Context, id uuid.UUID) error
RevokeAllUserRefreshTokens(ctx context.Context, id uuid.UUID) error
}
type usersResource struct {
JWTSecret string
Users UserStore
}
func (rs usersResource) Routes() chi.Router {
r := chi.NewRouter()
// Public routes (no tokens required)
r.Post("/", rs.Create) // POST /users - registration/signup
r.Post("/login", rs.Login) // POST /users/login - login as existing user
// Protected routes (access token required)
r.Group(func(r chi.Router) {
r.Use(requireAccessToken(rs.JWTSecret))
// Admin only general routes
r.Group(func(r chi.Router) {
r.Use(adminOnlyMiddleware)
r.Get("/", rs.List) // GET /users - list all users
})
// User specific routes
r.Route("/{id}", func(r chi.Router) {
r.Use(userCtx(rs.Users)) // DB -> req. context
// Admin routes
r.Route("/admin", func(r chi.Router) {
r.Use(adminOnlyMiddleware)
r.Get("/", rs.Get) // GET /users/admin/{id} - get single user
r.Delete("/", rs.AdminDelete) // DELETE /users/admin/{id} - delete user
})
// Owner routes
r.Route("/owner", func(r chi.Router) {
r.Use(ownerOnlyMiddleware)
r.Put("/", rs.UpdatePassword) // PUT /users/owner/{id} - update user password
r.Delete("/", rs.OwnerDelete) // DELETE /users/owner/{id} - delete user
})
})
})
return r
}
func (rs usersResource) Create(w http.ResponseWriter, r *http.Request) {
type request struct {
Username string `json:"username"`
Password string `json:"password"`
}
var req request
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body")
return
}
normalizedUsername := normalizeUsername(req.Username)
if err := validateUsername(normalizedUsername); err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
if err := validatePassword(req.Password); err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to create user")
return
}
user, err := rs.Users.CreateUser(r.Context(), data.CreateUserParams{
Username: normalizedUsername,
PasswordHash: string(hashedPassword),
})
if err != nil {
if isDuplicateEntry(err) {
respondError(w, http.StatusConflict, "Username is already in use")
} else {
respondError(w, http.StatusInternalServerError, "Failed to create user")
}
return
}
respondJSON(w, http.StatusCreated, map[string]string{
"id": user.ID.String(),
"username": user.Username,
})
}
func (rs usersResource) Login(w http.ResponseWriter, r *http.Request) {
type request struct {
Username string `json:"username"`
Password string `json:"password"`
}
var req request
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body")
return
}
user, err := rs.Users.GetUserByUsername(r.Context(), normalizeUsername(req.Username))
if err != nil {
respondError(w, http.StatusUnauthorized, "Invalid credentials")
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
respondError(w, http.StatusUnauthorized, "Invalid credentials")
return
}
tokenPair, err := generateTokenPair(user.ID.String(), user.IsAdmin, rs.JWTSecret)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to generate tokens")
return
}
// Set refresh token in HTTP-only cookie
http.SetCookie(w, &http.Cookie{
Name: "refresh_token",
Value: tokenPair.RefreshToken,
Path: "/",
MaxAge: int(refreshTokenDuration.Seconds()),
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
// Build response
response := map[string]any{
"access_token": tokenPair.AccessToken,
}
// Include user data if the client has requested it (`?includeUser=true`)
if includeUser, _ := strconv.ParseBool(r.URL.Query().Get("includeUser")); includeUser {
response["user"] = userResponse{
ID: user.ID,
Username: user.Username,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
IsAdmin: user.IsAdmin,
}
}
respondJSON(w, http.StatusOK, response)
}
func (rs usersResource) List(w http.ResponseWriter, r *http.Request) {
users, err := rs.Users.ListUsers(r.Context())
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to retrieve users")
return
}
// Output sanitization
var output []map[string]any
for _, user := range users {
output = append(output, map[string]any{
"id": user.ID,
"username": user.Username,
"created_at": user.CreatedAt,
"updated_at": user.UpdatedAt,
})
}
respondJSON(w, http.StatusOK, output)
}
func (rs usersResource) Get(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(userCtxKey{}).(data.User)
if !ok {
respondError(w, http.StatusNotFound, "User not found")
return
}
respondJSON(w, http.StatusOK, map[string]any{
"id": user.ID,
"username": user.Username,
"created_at": user.CreatedAt,
"updated_at": user.UpdatedAt,
})
}
func (rs usersResource) UpdatePassword(w http.ResponseWriter, r *http.Request) {
type request struct {
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"`
}
var req request
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body")
return
}
user, ok := r.Context().Value(userCtxKey{}).(data.User)
if !ok {
respondError(w, http.StatusNotFound, "User not found")
return
}
// Verify the old password before allowing the update
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.OldPassword)); err != nil {
respondError(w, http.StatusUnauthorized, "Invalid credentials")
return
}
if err := validatePassword(req.NewPassword); err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to update password")
return
}
if err := rs.Users.UpdatePassword(r.Context(), data.UpdatePasswordParams{
ID: user.ID,
PasswordHash: string(hashedPassword),
}); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to update password")
return
}
if err := rs.Users.RevokeAllUserRefreshTokens(r.Context(), user.ID); err != nil {
log.Error().Msgf("Failed to revoke refresh tokens: %s", err)
}
w.WriteHeader(http.StatusNoContent)
}
func (rs usersResource) AdminDelete(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(userCtxKey{}).(data.User)
if !ok {
respondError(w, http.StatusNotFound, "User not found")
return
}
if err := rs.Users.DeleteUser(r.Context(), user.ID); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to delete user")
return
}
if err := rs.Users.RevokeAllUserRefreshTokens(r.Context(), user.ID); err != nil {
log.Error().Msgf("Failed to revoke refresh tokens: %s", err)
}
w.WriteHeader(http.StatusNoContent)
}
func (rs usersResource) OwnerDelete(w http.ResponseWriter, r *http.Request) {
type request struct {
Password string `json:"password"`
}
var req request
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body")
return
}
user, ok := r.Context().Value(userCtxKey{}).(data.User)
if !ok {
respondError(w, http.StatusNotFound, "User not found")
return
}
// Verify the old password before allowing the deletion
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
respondError(w, http.StatusUnauthorized, "Invalid credentials")
return
}
err := rs.Users.DeleteUser(r.Context(), user.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to delete user")
return
}
if err := rs.Users.RevokeAllUserRefreshTokens(r.Context(), user.ID); err != nil {
log.Error().Msgf("Failed to revoke refresh tokens: %s", err)
}
w.WriteHeader(http.StatusNoContent)
}
// Check if the given error is a PostgreSQL error for `unique_violation`, i.e. whether an entry
// with the given details already exists in the database table.
func isDuplicateEntry(err error) bool {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
return pgErr.Code == "23505"
}
return false
}