diff options
Diffstat (limited to 'models')
| -rw-r--r-- | models/token.go | 145 | ||||
| -rw-r--r-- | models/user.go | 306 |
2 files changed, 261 insertions, 190 deletions
diff --git a/models/token.go b/models/token.go index de2880a..d0ded38 100644 --- a/models/token.go +++ b/models/token.go @@ -1,154 +1,75 @@ package models import ( - "auth-proxy/database" "auth-proxy/types" - "crypto/rand" - "database/sql" - "encoding/hex" + "auth-proxy/utils" "time" ) -// GenerateSecureToken creates a random, secure token. -func GenerateSecureToken(length int) (string, error) { - bytes := make([]byte, length) - if _, err := rand.Read(bytes); err != nil { - return "", err - } - return hex.EncodeToString(bytes), nil -} - -// CreateAuthToken creates a new token for a user, deleting any existing ones. -func CreateAuthToken(userID int64, duration time.Duration) (*types.AuthToken, error) { - tokenString, err := GenerateSecureToken(32) +func CreateAuthToken(user *types.User, duration time.Duration) (*types.AuthToken, error) { + tokenString, err := utils.GenerateJWT(user, duration, "session") if err != nil { return nil, err } - expiresAt := time.Now().Add(duration) - - tx, err := database.DB.Begin() - if err != nil { - return nil, err - } - defer tx.Rollback() //nolint:errcheck - - // Delete existing tokens for the user - deleteQuery := database.Rebind("DELETE FROM auth_tokens WHERE user_id = ?") - if _, err := tx.Exec(deleteQuery, userID); err != nil { - return nil, err - } - - // Insert the new token - insertQuery := database.Rebind("INSERT INTO auth_tokens (user_id, token, expires_at) VALUES (?, ?, ?)") - if _, err := tx.Exec(insertQuery, userID, tokenString, expiresAt); err != nil { - return nil, err - } - - if err := tx.Commit(); err != nil { - return nil, err - } - return &types.AuthToken{ - UserID: userID, + UserID: user.ID, Token: tokenString, - ExpiresAt: expiresAt, + ExpiresAt: time.Now().Add(duration), }, nil } -// GetUserByToken retrieves a user associated with a given token. -// It returns the user if the token is valid and not expired. func GetUserByToken(tokenString string) (*types.User, error) { - query := database.Rebind("SELECT u.id, u.username, u.password_hash, u.role, u.is_active, u.failed_logins, u.last_login_at, u.created_at, u.updated_at FROM users u JOIN auth_tokens t ON u.id = t.user_id WHERE t.token = ? AND t.expires_at > ?") - - row := database.DB.QueryRow(query, tokenString, time.Now()) - - user := &types.User{} - err := row.Scan(&user.ID, &user.Username, &user.PasswordHash, &user.Role, &user.IsActive, &user.FailedLogins, &user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt) + payload, err := utils.ValidateJWT(tokenString) if err != nil { - if err == sql.ErrNoRows { - return nil, nil // Token not found or expired, not a server error - } - return nil, err + return nil, nil + } + if payload.TokenType != "session" { + return nil, nil } - return user, nil + return GetUserByID(payload.UserID) } -// DeleteAuthToken deletes a token from the database. -func DeleteAuthToken(tokenString string) error { - query := database.Rebind("DELETE FROM auth_tokens WHERE token = ?") - _, err := database.DB.Exec(query, tokenString) - return err +func DeleteAuthToken(string) error { + return nil } -// DeleteExpiredTokens cleans up expired tokens from the database. func DeleteExpiredTokens() (int64, error) { - query := database.Rebind("DELETE FROM auth_tokens WHERE expires_at < ?") - res, err := database.DB.Exec(query, time.Now()) - if err != nil { - return 0, err - } - return res.RowsAffected() + return 0, nil } -// CreateRefreshToken creates a new refresh token for a user. -func CreateRefreshToken(userID int64, duration time.Duration) (*types.RefreshToken, error) { - tokenString, err := GenerateSecureToken(32) - if err != nil { - return nil, err - } - - expiresAt := time.Now().Add(duration) - - tx, err := database.DB.Begin() +func CreateRefreshToken(user *types.User, duration time.Duration) (*types.RefreshToken, error) { + tokenString, err := utils.GenerateJWT(user, duration, "refresh") if err != nil { return nil, err } - defer tx.Rollback() //nolint:errcheck - - // Delete existing, non-revoked refresh tokens for the user - deleteQuery := database.Rebind("DELETE FROM refresh_tokens WHERE user_id = ?") - if _, err := tx.Exec(deleteQuery, userID); err != nil { - return nil, err - } - - // Insert the new refresh token - insertQuery := database.Rebind("INSERT INTO refresh_tokens (user_id, token, expires_at) VALUES (?, ?, ?)") - if _, err := tx.Exec(insertQuery, userID, tokenString, expiresAt); err != nil { - return nil, err - } - - if err := tx.Commit(); err != nil { - return nil, err - } return &types.RefreshToken{ - UserID: userID, + UserID: user.ID, Token: tokenString, - ExpiresAt: expiresAt, + ExpiresAt: time.Now().Add(duration), + IsRevoked: false, }, nil } -// GetRefreshTokenByToken retrieves a refresh token by its token string. func GetRefreshTokenByToken(tokenString string) (*types.RefreshToken, error) { - query := database.Rebind("SELECT id, user_id, token, expires_at, is_revoked FROM refresh_tokens WHERE token = ?") - row := database.DB.QueryRow(query, tokenString) - - token := &types.RefreshToken{} - err := row.Scan(&token.ID, &token.UserID, &token.Token, &token.ExpiresAt, &token.IsRevoked) + payload, err := utils.ValidateJWT(tokenString) if err != nil { - if err == sql.ErrNoRows { - return nil, nil // Not found - } - return nil, err + return nil, nil } - return token, nil + if payload.TokenType != "refresh" { + return nil, nil + } + + return &types.RefreshToken{ + UserID: payload.UserID, + Token: tokenString, + ExpiresAt: time.Unix(payload.Exp, 0), + IsRevoked: false, + }, nil } -// DeleteRefreshTokenByToken deletes a refresh token from the database by its token string. -func DeleteRefreshTokenByToken(tokenString string) error { - query := database.Rebind("DELETE FROM refresh_tokens WHERE token = ?") - _, err := database.DB.Exec(query, tokenString) - return err +func DeleteRefreshTokenByToken(string) error { + return nil } 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 "" } |
