diff options
| author | haturatu <taro@eyes4you.org> | 2026-04-22 04:46:24 +0900 |
|---|---|---|
| committer | haturatu <taro@eyes4you.org> | 2026-04-22 04:46:24 +0900 |
| commit | 4b0271272e983fdb85d4b816cbd91f34d91a7884 (patch) | |
| tree | ad92ba661f0f9e8b16d93f333ebf43ffd331a892 /models/user.go | |
| parent | d18b4f9d615d8942ff604a53be92da2c8e853635 (diff) | |
Diffstat (limited to 'models/user.go')
| -rw-r--r-- | models/user.go | 306 |
1 files changed, 228 insertions, 78 deletions
diff --git a/models/user.go b/models/user.go index f2b4e22..de440cc 100644 --- a/models/user.go +++ b/models/user.go @@ -3,132 +3,282 @@ package models import ( "auth-proxy/database" "auth-proxy/types" - "database/sql" - - "golang.org/x/crypto/bcrypt" + "context" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" ) -// HashPassword generates a bcrypt hash of the password -func HashPassword(password string) (string, error) { - bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14) - return string(bytes), err +type pbUserRecord struct { + ID string `json:"id"` + Username string `json:"username"` + Role string `json:"role"` + IsActive *bool `json:"is_active"` + FailedLogins any `json:"failed_logins"` + LastLoginAt string `json:"last_login_at"` + Created string `json:"created"` + Updated string `json:"updated"` } -// CheckPasswordHash compares a password with a hash -func CheckPasswordHash(password, hash string) bool { - err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) - return err == nil +func parsePBTime(raw string) (*time.Time, error) { + if raw == "" { + return nil, nil + } + + formats := []string{time.RFC3339Nano, "2006-01-02 15:04:05.000Z", "2006-01-02 15:04:05.000"} + for _, format := range formats { + if ts, err := time.Parse(format, raw); err == nil { + return &ts, nil + } + } + + return nil, fmt.Errorf("unsupported time format: %s", raw) } -// CreateUser creates a new user in the database -func CreateUser(username, password, role string) (*types.User, error) { - passwordHash, err := HashPassword(password) +func parsePBTimeValue(raw string) (time.Time, error) { + ts, err := parsePBTime(raw) if err != nil { - return nil, err + return time.Time{}, err } + if ts == nil { + return time.Time{}, nil + } + return *ts, nil +} + +func parseFailedLogins(value any) int { + switch v := value.(type) { + case float64: + return int(v) + case int: + return v + case int64: + return int(v) + case string: + i, _ := strconv.Atoi(v) + return i + default: + return 0 + } +} + +func normalizeRole(role string) string { + if role == "admin" { + return "admin" + } + return "user" +} - query := database.Rebind("INSERT INTO users(username, password_hash, role) VALUES(?, ?, ?)") - stmt, err := database.DB.Prepare(query) +func normalizeUser(record pbUserRecord) (*types.User, error) { + lastLoginAt, err := parsePBTime(record.LastLoginAt) if err != nil { return nil, err } - defer stmt.Close() //nolint:errcheck + createdAt, err := parsePBTimeValue(record.Created) + if err != nil { + return nil, err + } + updatedAt, err := parsePBTimeValue(record.Updated) + if err != nil { + return nil, err + } + + isActive := true + if record.IsActive != nil { + isActive = *record.IsActive + } - _, err = stmt.Exec(username, passwordHash, role) + return &types.User{ + ID: record.ID, + Username: record.Username, + Role: normalizeRole(record.Role), + IsActive: isActive, + FailedLogins: parseFailedLogins(record.FailedLogins), + LastLoginAt: lastLoginAt, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }, nil +} + +func CreateUser(username, password, role string) (*types.User, error) { + record := pbUserRecord{} + err := database.PB.DoWithSuperuser(context.Background(), http.MethodPost, "/api/collections/"+url.PathEscape(database.PB.Collection())+"/records", nil, map[string]any{ + "username": username, + "email": database.PB.SyntheticEmail(username), + "password": password, + "passwordConfirm": password, + "role": normalizeRole(role), + "is_active": true, + "failed_logins": 0, + "last_login_at": "", + "emailVisibility": false, + "verified": true, + }, &record) if err != nil { return nil, err } - return GetUserByUsername(username) // Return the full user object + return normalizeUser(record) } -// baseSelectQuery is the common set of columns for user queries -const baseSelectQuery = "SELECT id, username, password_hash, role, is_active, failed_logins, last_login_at, created_at, updated_at FROM users" +func AuthenticateWithPassword(username, password string) (*types.User, error) { + resp := struct { + Token string `json:"token"` + Record pbUserRecord `json:"record"` + }{} -// scanUser scans a row into a User struct -func scanUser(row *sql.Row) (*types.User, error) { - user := &types.User{} - err := row.Scan(&user.ID, &user.Username, &user.PasswordHash, &user.Role, &user.IsActive, &user.FailedLogins, &user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt) + err := database.PB.Do(context.Background(), http.MethodPost, "/api/collections/"+url.PathEscape(database.PB.Collection())+"/auth-with-password", "", map[string]any{ + "identity": username, + "password": password, + "identityField": database.PB.IdentityField(), + }, &resp) if err != nil { - if err == sql.ErrNoRows { - return nil, nil // User not found - } return nil, err } - return user, nil + + return normalizeUser(resp.Record) } -// GetUserByUsername retrieves a user by their username func GetUserByUsername(username string) (*types.User, error) { - query := database.Rebind(baseSelectQuery + " WHERE username = ?") - row := database.DB.QueryRow(query, username) - return scanUser(row) + query := url.Values{} + query.Set("filter", fmt.Sprintf("%s=%s", database.PB.IdentityField(), database.QuoteFilterValue(username))) + query.Set("perPage", "1") + + resp := apiListResponse[pbUserRecord]{} + err := database.PB.DoWithSuperuser(context.Background(), http.MethodGet, "/api/collections/"+url.PathEscape(database.PB.Collection())+"/records", query, nil, &resp) + if err != nil { + return nil, err + } + if len(resp.Items) == 0 { + return nil, nil + } + + return normalizeUser(resp.Items[0]) } -// GetUserByID retrieves a user by their ID -func GetUserByID(id int64) (*types.User, error) { - query := database.Rebind(baseSelectQuery + " WHERE id = ?") - row := database.DB.QueryRow(query, id) - return scanUser(row) +func GetUserByID(id string) (*types.User, error) { + record := pbUserRecord{} + err := database.PB.DoWithSuperuser(context.Background(), http.MethodGet, "/api/collections/"+url.PathEscape(database.PB.Collection())+"/records/"+url.PathEscape(id), nil, nil, &record) + if err != nil { + apiErr, ok := err.(*database.APIError) + if ok && apiErr.StatusCode == http.StatusNotFound { + return nil, nil + } + return nil, err + } + + return normalizeUser(record) } -// GetAllUsers retrieves all users from the database func GetAllUsers() ([]types.User, error) { - rows, err := database.DB.Query(baseSelectQuery) + query := url.Values{} + query.Set("perPage", "500") + query.Set("sort", "username") + + resp := apiListResponse[pbUserRecord]{} + err := database.PB.DoWithSuperuser(context.Background(), http.MethodGet, "/api/collections/"+url.PathEscape(database.PB.Collection())+"/records", query, nil, &resp) if err != nil { return nil, err } - defer rows.Close() //nolint:errcheck - var users []types.User - for rows.Next() { - var user types.User - if err := rows.Scan(&user.ID, &user.Username, &user.PasswordHash, &user.Role, &user.IsActive, &user.FailedLogins, &user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt); err != nil { - return nil, err + users := make([]types.User, 0, len(resp.Items)) + for _, item := range resp.Items { + user, convErr := normalizeUser(item) + if convErr != nil { + return nil, convErr } - users = append(users, user) + users = append(users, *user) } + return users, nil } -// UpdateUserRole updates a user's role -func UpdateUserRole(id int64, role string) error { - query := database.Rebind("UPDATE users SET role = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?") - _, err := database.DB.Exec(query, role, id) - return err +func UpdateUserRole(id, role string) error { + return patchUser(id, map[string]any{ + "role": normalizeRole(role), + }) +} + +func DeleteUser(id string) error { + return database.PB.DoWithSuperuser(context.Background(), http.MethodDelete, "/api/collections/"+url.PathEscape(database.PB.Collection())+"/records/"+url.PathEscape(id), nil, nil, nil) } -// DeleteUser deletes a user from the database -func DeleteUser(id int64) error { - query := database.Rebind("DELETE FROM users WHERE id = ?") - _, err := database.DB.Exec(query, id) - return err +func ChangeUserPassword(id, currentPassword, newPassword string) error { + return patchUser(id, map[string]any{ + "oldPassword": currentPassword, + "password": newPassword, + "passwordConfirm": newPassword, + }) } -// UpdateUserPassword updates a user's password hash -func UpdateUserPassword(id int64, passwordHash string) error { - query := database.Rebind("UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?") - _, err := database.DB.Exec(query, passwordHash, id) - return err +func RecordLoginSuccess(id string) error { + return patchUser(id, map[string]any{ + "failed_logins": 0, + "last_login_at": time.Now().UTC().Format(time.RFC3339), + "is_active": true, + }) } -// RecordLoginSuccess resets failed login attempts and updates last login time -func RecordLoginSuccess(id int64) error { - query := database.Rebind("UPDATE users SET failed_logins = 0, last_login_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ?") - _, err := database.DB.Exec(query, id) - return err +func RecordLoginFailure(id string) error { + user, err := GetUserByID(id) + if err != nil { + return err + } + if user == nil { + return nil + } + + return patchUser(id, map[string]any{ + "failed_logins": user.FailedLogins + 1, + }) } -// RecordLoginFailure increments the failed login counter -func RecordLoginFailure(id int64) error { - query := database.Rebind("UPDATE users SET failed_logins = failed_logins + 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?") - _, err := database.DB.Exec(query, id) - return err +func SetUserActiveStatus(id string, isActive bool) error { + return patchUser(id, map[string]any{ + "is_active": isActive, + }) } -// SetUserActiveStatus updates the is_active flag for a user -func SetUserActiveStatus(id int64, isActive bool) error { - query := database.Rebind("UPDATE users SET is_active = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?") - _, err := database.DB.Exec(query, isActive, id) - return err +func patchUser(id string, body map[string]any) error { + return database.PB.DoWithSuperuser(context.Background(), http.MethodPatch, "/api/collections/"+url.PathEscape(database.PB.Collection())+"/records/"+url.PathEscape(id), nil, body, nil) +} + +type apiListResponse[T any] struct { + Items []T `json:"items"` +} + +func IsUnauthorized(err error) bool { + return database.IsUnauthorized(err) +} + +func IsValidationConflict(err error) bool { + apiErr, ok := err.(*database.APIError) + if !ok { + return false + } + return apiErr.StatusCode == http.StatusBadRequest || apiErr.StatusCode == http.StatusConflict +} + +func ValidationMessage(err error) string { + apiErr, ok := err.(*database.APIError) + if !ok || len(apiErr.Details) == 0 { + return "" + } + + for _, key := range []string{"username", "password", "email", "role"} { + raw, exists := apiErr.Details[key] + if !exists { + continue + } + if detail, ok := raw.(map[string]any); ok { + if msg, ok := detail["message"].(string); ok && strings.TrimSpace(msg) != "" { + return msg + } + } + } + + return "" } |
