746 lines
24 KiB
Go

package service
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"git.umbrella.haus/ae/notatest/internal/data"
"github.com/go-chi/chi/v5"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/gorilla/csrf"
"github.com/jackc/pgx/v5/pgconn"
"github.com/rs/zerolog/log"
"golang.org/x/crypto/bcrypt"
)
const (
accessTokenDuration = 15 * time.Minute
refreshTokenDuration = 7 * 24 * time.Hour
authCookieName = "notatest.refresh_token"
viewCookieName = "notatest.expires_at"
authCookiePath = "/api/auth/cookie"
)
var (
ErrInvalidToken = errors.New("invalid token")
ErrAuthHeaderInvalid = errors.New("token couldn't be parsed from Authorization header")
)
// User object context key for incoming requests (handled by middlewares). Only `*userClaims` type
// objects should be stored behind this key for consistency.
type userCtxKey struct{}
// DTO without sensitive data fields such as user's password hash.
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"`
}
// Custom JWT claims (should always be handled in the middleware layer).
type userClaims struct {
Admin bool `json:"admin"`
TokenType string `json:"type"` // "access" or "refresh"
jwt.RegisteredClaims // User's UUID should be stored in the subject claim
}
type tokenPair struct {
AccessToken string
RefreshToken string
}
// Mockable token related database operations interface.
type TokenStore interface {
CreateRefreshToken(ctx context.Context, arg data.CreateRefreshTokenParams) (data.RefreshToken, error)
GetRefreshTokenByHash(ctx context.Context, tokenHash string) (data.RefreshToken, error)
RevokeRefreshToken(ctx context.Context, tokenHash string) error
RevokeAllUserRefreshTokens(ctx context.Context, id uuid.UUID) error
}
// Mockable user related database operations interface.
type UserStore interface {
CreateUser(ctx context.Context, arg data.CreateUserParams) (data.User, error)
ListUsers(ctx context.Context, arg data.ListUsersParams) ([]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) (data.User, error)
DeleteUser(ctx context.Context, id uuid.UUID) error
RevokeAllUserRefreshTokens(ctx context.Context, id uuid.UUID) error
}
// Chi HTTP router for authentication/authorization related actions (users/tokens). In theory
// (especially in production) the `UserStore` and `TokenStore` will point to the same database
// handler, but for code readability they should be kept in separate structs.
type authResource struct {
Config SvcConfig
Users UserStore
Tokens TokenStore
}
func (rs authResource) Routes() chi.Router {
r := chi.NewRouter()
// Public routes
r.Post("/signup", rs.Create) // POST /auth/signup - registration
r.Post("/login", rs.Login) // POST /auth/login - login
// Protected routes (access token required)
r.Group(func(r chi.Router) {
r.Use(requireAccessToken(rs.Config.JWTSecret)) // JWT claims -> ctx.
r.Get("/me", rs.Get) // GET /auth/me - current user data
r.Post("/logout", rs.Logout) // POST /auth/logout - revoke all refresh cookies
// Owner routes
r.Route("/owner", func(r chi.Router) {
r.Put("/", rs.UpdatePassword) // PUT /auth/owner - update user password
r.Delete("/", rs.OwnerDelete) // DELETE /auth/owner - delete user (owner)
})
// Administration routes (admin claim required)
r.Route("/admin", func(r chi.Router) {
r.Use(adminOnlyMiddleware)
r.Get("/all", rs.List) // GET /auth/admin/all - list all users
r.Route(fmt.Sprintf("/{%s}", targetUserUUIDCtxParameter), func(r chi.Router) {
r.Use(uuidCtx(targetUserUUIDCtxParameter))
r.Delete("/", rs.AdminDelete) // DELETE /auth/admin/{id} - delete user (admin)
})
})
})
// Protected routes (refresh token required)
r.Route("/cookie", func(r chi.Router) {
// The refresh token httpOnly cookie is restricted to `/api/auth/cookie`, which is why this
// is the only endpoint where CSRF must be taken into account (HTTPS requirement disabled
// for local development)
if rs.Config.IsProd {
r.Use(csrf.Protect([]byte(rs.Config.CSRFSecret)))
} else {
r.Use(csrf.Protect([]byte(rs.Config.CSRFSecret), csrf.Secure(false)))
}
r.Use(requireRefreshToken(rs.Config.JWTSecret))
r.Get("/csrf", rs.GetCSRFToken) // GET /auth/cookie/csrf - get a new CSRF token in response headers
r.Post("/refresh", rs.RefreshAccessToken) // POST /auth/cookie/refresh - convert refresh token to new token pair
})
return r
}
// Handler for new user creation. Will check the incoming JSON object's integrity, validate/normalize
// the username, and validate the password (check whether it's compromised via the HIBP API and
// calculate its entropy).
func (rs authResource) 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 || req.Username == nil || req.Password == nil {
respondError(w, http.StatusBadRequest, "Invalid request body")
return
}
// Username normalization (to lowercase)
normalizedUsername := normalizeUsername(*req.Username)
if err := validateUsername(normalizedUsername); err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
// Password validation (length, HIBP API, and entropy)
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,
})
}
// Handler for logging in to an existing user account using a username-password credentials pair.
// Will check the incoming JSON object's integrity, use a normalized version of the username for a
// database lookup, and compare the given password's hash against the one stored in the database.
// By default only returns a fresh access tokens (and a refresh token as a httpOnly cookie), but
// if the `includeUser` parameter is set to `true` the user DTO will also be included.
func (rs authResource) 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 || req.Username == nil || req.Password == 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
}
// Generate a new access/refresh token pair and store a SHA256 hash of the refresh token into
// the database for further token rotations.
tokenPair, err := rs.GenerateTokenPair(r.Context(), user.ID, user.IsAdmin)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to generate tokens")
return
}
rs.setAuthCookies(w, tokenPair, false)
// Build response
response := map[string]any{
"access_token": tokenPair.AccessToken,
}
// Include user data DTO into the response if the `includeUser` parameter was set to `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)
}
// Handler for getting full data of the current user as a DTO (database lookup) based on the JWT
// claims set into the request's context by a middleware.
func (rs authResource) Get(w http.ResponseWriter, r *http.Request) {
user := rs.userFromCtxClaims(w, r)
if user == nil {
return
}
respondJSON(w, http.StatusOK, userResponse{
ID: user.ID,
Username: user.Username,
IsAdmin: user.IsAdmin,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
})
}
// Handler for updating the current user's password. Performs the same password strength checks as
// the registration handler (`rs.Create`) and revokes any existing refresh tokens the user has
// stored in the database. The new access token and the updated user object's DTO will be returned.
func (rs authResource) 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 := rs.userFromCtxClaims(w, r)
if user == nil {
return
}
// Verify the old password before proceeding with 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
}
nUSer, err := rs.Users.UpdatePassword(r.Context(), data.UpdatePasswordParams{
ID: user.ID,
PasswordHash: string(hashedPassword),
})
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to update password")
return
}
// Revoke all old tokens before generating a new one for this session
if err := rs.Users.RevokeAllUserRefreshTokens(r.Context(), user.ID); err != nil {
log.Error().Msgf("Failed to revoke refresh tokens: %s", err)
}
// Generate a new pair (access & refresh tokens)
tokenPair, err := rs.GenerateTokenPair(r.Context(), user.ID, user.IsAdmin)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to generate tokens")
return
}
rs.setAuthCookies(w, tokenPair, false)
response := map[string]any{
"access_token": tokenPair.AccessToken,
"user": userResponse{
ID: nUSer.ID,
Username: nUSer.Username,
IsAdmin: nUSer.IsAdmin,
CreatedAt: nUSer.CreatedAt,
UpdatedAt: nUSer.UpdatedAt,
},
}
// Return the new access token and the updated user object
respondJSON(w, http.StatusOK, response)
}
// Handler for hard deleting the current user. Requires the user's password as JSON input as a precaution.
func (rs authResource) 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 := rs.userFromCtxClaims(w, r)
if user == nil {
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
}
// Clear the refresh token cookie
rs.setAuthCookies(w, nil, true)
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)
}
// Handler for listing all users stored in the database. Should only be allowed to be called by
// administrator level users.
func (rs authResource) List(w http.ResponseWriter, r *http.Request) {
limit, offset := getPaginationParams(r)
users, err := rs.Users.ListUsers(r.Context(), data.ListUsersParams{
Limit: limit,
Offset: offset,
})
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to retrieve users")
return
}
// Output sanitization to DTO
var output []userResponse
for _, user := range users {
output = append(output, userResponse{
ID: user.ID,
Username: user.Username,
IsAdmin: user.IsAdmin,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
})
}
respondJSON(w, http.StatusOK, output)
}
// Handler for deleting another user account based on their ID. Will check the existence of the
// user based on the given ID and additionally revoke all the stored refresh tokens on successful
// deletion. Should only be allowed to be called by administrator level users.
func (rs authResource) AdminDelete(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
targetID, ok := ctx.Value(uuidCtxKey{Name: targetUserUUIDCtxParameter}).(uuid.UUID)
if !ok {
respondError(w, http.StatusBadRequest, "Resource ID missing")
return
}
if err := rs.Users.DeleteUser(r.Context(), targetID); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to delete user")
return
}
if err := rs.Users.RevokeAllUserRefreshTokens(r.Context(), targetID); err != nil {
log.Error().Msgf("Failed to revoke refresh tokens: %s", err)
}
w.WriteHeader(http.StatusNoContent)
}
// Generate a new pair of access and refresh tokens (JWTs) with the user's UUID as the identifying
// `Subject` claim and custom claims for the user's administrator status (boolean) and token type
// ("refresh"/"access"). Stores a SHA256 hash of the refresh token into the database for further
// token rotations.
func (rs authResource) GenerateTokenPair(ctx context.Context, userID uuid.UUID, isAdmin bool) (*tokenPair, error) {
tokenPair, err := generateTokenPair(userID.String(), isAdmin, rs.Config.JWTSecret)
if err != nil {
return nil, err
}
hash := sha256.Sum256([]byte(tokenPair.RefreshToken))
tokenHash := hex.EncodeToString(hash[:])
// Store the SHA256 hash of the refresh token with (almost) identical expiration timestamp
expiresAt := time.Now().Add(refreshTokenDuration)
_, err = rs.Tokens.CreateRefreshToken(ctx, data.CreateRefreshTokenParams{
UserID: userID,
TokenHash: tokenHash,
ExpiresAt: expiresAt,
})
if err != nil {
return nil, err
}
return tokenPair, nil
}
// Revoke the given token from the database by calculating its SHA256 hash.
func (rs authResource) RevokeRefreshToken(ctx context.Context, token string) error {
hash := sha256.Sum256([]byte(token))
tokenHash := hex.EncodeToString(hash[:])
return rs.Tokens.RevokeRefreshToken(ctx, tokenHash)
}
// Validate the given refresh token by performing a database lookup with its SHA256 hash. Returns
// the refresh token database object on successful lookup. Fails if the token has been revoked
// (soft database operation) or expired (i.e. the corresponding user has to log in again).
func (rs authResource) ValidateRefreshToken(ctx context.Context, token string) (*data.RefreshToken, error) {
hash := sha256.Sum256([]byte(token))
tokenHash := hex.EncodeToString(hash[:])
dbToken, err := rs.Tokens.GetRefreshTokenByHash(ctx, tokenHash)
if err != nil {
return nil, err
}
// Check for soft revocation and/or expiration
if dbToken.Revoked || time.Now().After(dbToken.ExpiresAt) {
return nil, ErrInvalidToken
}
return &dbToken, nil
}
// Handler for returning the CSRF token in the `X-CSRF-Token` header. Notably this request doesn't
// need to contain a valid CSRF token as it uses "safe" (non-mutating) method GET, which means CSRF
// won't be enforced.
func (rs authResource) GetCSRFToken(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-CSRF-Token", csrf.Token(r))
w.WriteHeader(http.StatusNoContent)
}
// Handler for performing a token rotation, i.e. invalidating the given refresh token (each refresh
// token is a single use utility) and exchanging it for a new pair of refresh and access tokens.
func (rs authResource) RefreshAccessToken(w http.ResponseWriter, r *http.Request) {
// Get claims from context
claims, ok := r.Context().Value(userCtxKey{}).(*userClaims)
if !ok || claims.TokenType != "refresh" {
respondError(w, http.StatusUnauthorized, "Invalid token")
return
}
// Attempt to get the token from httpOnly cookie
refreshToken, err := getTokenFromCookie(r)
if err != nil {
respondError(w, http.StatusUnauthorized, "Unauthorized")
return
}
// Validate the refresh token in the database
if _, err := rs.ValidateRefreshToken(r.Context(), refreshToken); err != nil {
respondError(w, http.StatusUnauthorized, "Invalid refresh token")
return
}
// Revoke the given (single use) refresh token
if err := rs.RevokeRefreshToken(r.Context(), refreshToken); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to revoke token")
return
}
userID, err := uuid.Parse(claims.Subject)
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid user ID")
return
}
// Generate a new pair (access & refresh tokens)
tokenPair, err := rs.GenerateTokenPair(r.Context(), userID, claims.Admin)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to generate tokens")
return
}
rs.setAuthCookies(w, tokenPair, false)
// Return the access token in the response body (it should be stored in browser's memory client-side)
respondJSON(w, http.StatusOK, map[string]string{
"access_token": tokenPair.AccessToken,
})
}
// Handler for performing a logout process for the current user, i.e. replacing the current
// httpOnly `refresh_token` cookie with one that expires immediately. Theoretically the user
// will still be able to authenticate until the access token (stored client-side) expires,
// but that's up to the client to handle.
func (rs authResource) Logout(w http.ResponseWriter, r *http.Request) {
claims, ok := r.Context().Value(userCtxKey{}).(*userClaims)
if !ok {
respondError(w, http.StatusUnauthorized, "Not authenticated")
return
}
userID, err := uuid.Parse(claims.Subject)
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid user ID")
return
}
// Clear the refresh token cookie
rs.setAuthCookies(w, nil, true)
if err := rs.Tokens.RevokeAllUserRefreshTokens(r.Context(), userID); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to logout")
return
}
w.WriteHeader(http.StatusNoContent)
}
// Parse JWT claims (`userClaims`) from the request's context, and perform a database lookup based
// on `Subject` (after parsing it to `uuid.UUID`) to fetch the corresponding user's data.
func (rs authResource) userFromCtxClaims(w http.ResponseWriter, r *http.Request) *data.User {
claims, ok := r.Context().Value(userCtxKey{}).(*userClaims)
if !ok {
respondError(w, http.StatusUnauthorized, "Unauthorized")
return nil
}
userID, err := uuid.Parse(claims.Subject)
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid user ID")
return nil
}
user, err := rs.Users.GetUserByID(r.Context(), userID)
if err != nil {
respondError(w, http.StatusNotFound, "User not found")
return nil
}
return &user
}
func (rs authResource) setAuthCookies(w http.ResponseWriter, tokenPair *tokenPair, clearCookies bool) {
expirationTime := time.Now().Add(refreshTokenDuration)
expirationUnix := strconv.FormatInt(expirationTime.Unix(), 10)
log.Debug().Msgf("Setting authentication cookies (clearCookies: %t)", clearCookies)
var maxAge int
var value string
if clearCookies {
maxAge = 0 // Expires immediately
value = ""
} else {
maxAge = int(refreshTokenDuration.Seconds())
value = tokenPair.RefreshToken
}
// The actual auth cookie is httpOnly, i.e. not viewable by the client
http.SetCookie(w, &http.Cookie{
Name: authCookieName,
Value: value,
Domain: rs.Config.Domain,
Path: authCookiePath,
MaxAge: maxAge,
HttpOnly: true,
Secure: rs.Config.IsProd,
SameSite: http.SameSiteStrictMode,
})
// The information cookie can be used by the client to check how long it'll take until the
// actual auth cookie expires (notably `HttpOnly: false` is a must)
http.SetCookie(w, &http.Cookie{
Name: viewCookieName,
Value: expirationUnix,
Domain: rs.Config.Domain,
Path: authCookiePath,
MaxAge: maxAge,
HttpOnly: false,
Secure: rs.Config.IsProd,
SameSite: http.SameSiteStrictMode,
})
}
// Helper function for generating the initial administrator level account if one doesn't already
// exists in the database.
func CreateAdminIfNotExists(ctx context.Context, q *data.Queries, username, password string) error {
admins, err := q.ListAdmins(ctx)
if err != nil {
return err
}
if len(admins) > 0 {
log.Debug().Msg("Admin accounts already exist, skipping creation")
return nil
}
// Username normalization (to lowercase)
normalizedUsername := normalizeUsername(username)
if err := validateUsername(normalizedUsername); err != nil {
return err
}
// Password validation (length, HIBP API, and entropy)
if err := validatePassword(password); err != nil {
return err
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
_, err = q.CreateAdmin(ctx, data.CreateAdminParams{
Username: normalizedUsername,
PasswordHash: string(hashedPassword),
})
if err != nil {
return err
}
log.Info().Msgf("Initial admin user '%s' created successfully", username)
return nil
}
// Parse the JWT token from the request's Authorization header.
func getTokenFromHeader(r *http.Request) (string, error) {
bearerToken := r.Header.Get("Authorization")
bearerFields := strings.Fields(bearerToken)
if len(bearerFields) == 2 && strings.ToLower(bearerFields[0]) == "bearer" {
return bearerFields[1], nil
}
return "", ErrAuthHeaderInvalid
}
// Parse the JWT token from the request's cookies (httpOnly cookie).
func getTokenFromCookie(r *http.Request) (string, error) {
cookie, err := r.Cookie("notatest.refresh_token")
if err != nil {
return "", err
}
return cookie.Value, nil
}
// Helper function for generating a new JWT token pair with the given specifications.
func generateTokenPair(userID string, isAdmin bool, jwtSecret string) (*tokenPair, error) {
atClaims := userClaims{
Admin: isAdmin,
TokenType: "access",
RegisteredClaims: jwt.RegisteredClaims{
Subject: userID,
ExpiresAt: jwt.NewNumericDate(time.Now().Add(accessTokenDuration)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
},
}
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, atClaims)
t, err := accessToken.SignedString([]byte(jwtSecret))
if err != nil {
return nil, err
}
rtClaims := userClaims{
Admin: isAdmin,
TokenType: "refresh",
RegisteredClaims: jwt.RegisteredClaims{
Subject: userID,
ExpiresAt: jwt.NewNumericDate(time.Now().Add(refreshTokenDuration)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
},
}
refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, rtClaims)
rt, err := refreshToken.SignedString([]byte(jwtSecret))
if err != nil {
return nil, err
}
return &tokenPair{AccessToken: t, RefreshToken: rt}, nil
}
// Check if the given error is a PostgreSQL error for `unique_violation` (error code 23505), 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
}