357 lines
9.7 KiB
Go
357 lines
9.7 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"`
|
|
IsAdmin bool `json:"is_admin"`
|
|
CreatedAt *time.Time `json:"created_at"`
|
|
UpdatedAt *time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// 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))
|
|
|
|
r.Get("/me", rs.Get) // GET /users/me - get current user data
|
|
|
|
// 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.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,
|
|
IsAdmin: user.IsAdmin,
|
|
CreatedAt: user.CreatedAt,
|
|
UpdatedAt: user.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
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) {
|
|
claims, ok := r.Context().Value(userCtxKey{}).(*userClaims)
|
|
if !ok {
|
|
respondError(w, http.StatusUnauthorized, "Unauthorized")
|
|
return
|
|
}
|
|
|
|
userID, err := uuid.Parse(claims.Subject)
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "Invalid user ID")
|
|
return
|
|
}
|
|
|
|
user, err := rs.Users.GetUserByID(r.Context(), userID)
|
|
if err != nil {
|
|
respondError(w, http.StatusNotFound, "User not found")
|
|
return
|
|
}
|
|
|
|
respondJSON(w, http.StatusOK, userResponse{
|
|
ID: user.ID,
|
|
Username: user.Username,
|
|
IsAdmin: user.IsAdmin,
|
|
CreatedAt: user.CreatedAt,
|
|
UpdatedAt: 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
|
|
}
|