Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/api/internal/openplatform/portal.go
2026-07-27 16:46:15 +08:00

676 lines
24 KiB
Go

package openplatform
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
const (
portalMaxLoginFailures = 5
portalLoginLock = 15 * time.Minute
)
var portalUsernamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]{3,64}$`)
type PortalService struct {
db *sql.DB
sessionTTL time.Duration
now func() time.Time
}
type portalCredential struct {
PortalUser
PasswordHash string
FailedLoginCount int
LockedUntil sql.NullTime
UserType string
}
func NewPortalService(db *sql.DB, sessionTTL time.Duration) *PortalService {
if db == nil {
panic("open platform portal database is required")
}
if sessionTTL <= 0 {
sessionTTL = 12 * time.Hour
}
return &PortalService{db: db, sessionTTL: sessionTTL, now: time.Now}
}
func (s *PortalService) CreateUser(ctx context.Context, input PortalUserInput, actor string) (PortalUser, error) {
from, to, err := validatePortalUserInput(&input, true)
if err != nil {
return PortalUser{}, err
}
if reserved, err := s.platformAdminUsername(ctx, input.Username); err != nil {
return PortalUser{}, err
} else if reserved {
return PortalUser{}, fmt.Errorf("%w: username is reserved by a platform administrator", ErrInvalidRequest)
}
hash, err := bcrypt.GenerateFromPassword([]byte(input.Password), 12)
if err != nil {
return PortalUser{}, err
}
result, err := s.db.ExecContext(ctx, `
INSERT INTO vehicle_open_user(
username,display_name,password_hash,status,valid_from,valid_to,created_by,updated_by
) VALUES(?,?,?,?,?,?,?,?)`,
input.Username, input.DisplayName, string(hash), input.Status, from, nullableTime(to), actor, actor)
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "duplicate") {
return PortalUser{}, fmt.Errorf("%w: username already exists", ErrInvalidRequest)
}
return PortalUser{}, err
}
id, err := result.LastInsertId()
if err != nil {
return PortalUser{}, err
}
_ = s.userAudit(ctx, uint64(id), actor, "user.create", "success", map[string]any{"username": input.Username}, "")
return s.user(ctx, uint64(id))
}
func (s *PortalService) ListUsers(ctx context.Context) ([]PortalUser, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id,username,display_name,status,valid_from,valid_to,last_login_at,created_by,created_at,updated_at
FROM vehicle_open_user ORDER BY id DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
users := make([]PortalUser, 0)
for rows.Next() {
user, err := scanPortalUser(rows)
if err != nil {
return nil, err
}
users = append(users, user)
}
return users, rows.Err()
}
func (s *PortalService) UpdateUser(ctx context.Context, id uint64, input PortalUserInput, actor string) (PortalUser, error) {
from, to, err := validatePortalUserInput(&input, false)
if err != nil {
return PortalUser{}, err
}
if reserved, err := s.platformAdminUsername(ctx, input.Username); err != nil {
return PortalUser{}, err
} else if reserved {
return PortalUser{}, fmt.Errorf("%w: username is reserved by a platform administrator", ErrInvalidRequest)
}
args := []any{input.Username, input.DisplayName, input.Status, from, nullableTime(to), actor}
query := `UPDATE vehicle_open_user SET username=?,display_name=?,status=?,valid_from=?,valid_to=?,updated_by=?`
if input.Password != "" {
hash, err := bcrypt.GenerateFromPassword([]byte(input.Password), 12)
if err != nil {
return PortalUser{}, err
}
query += `,password_hash=?,password_changed_at=NOW(3),failed_login_count=0,locked_until=NULL`
args = append(args, string(hash))
}
query += ` WHERE id=?`
args = append(args, id)
result, err := s.db.ExecContext(ctx, query, args...)
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "duplicate") {
return PortalUser{}, fmt.Errorf("%w: username already exists", ErrInvalidRequest)
}
return PortalUser{}, err
}
if affected, _ := result.RowsAffected(); affected == 0 {
return PortalUser{}, ErrNotFound
}
if input.Status == "disabled" || input.Password != "" {
_, _ = s.db.ExecContext(ctx, `UPDATE vehicle_open_user_session SET revoked_at=NOW(3) WHERE user_id=? AND revoked_at IS NULL`, id)
}
_ = s.userAudit(ctx, id, actor, "user.update", "success", map[string]any{"status": input.Status, "passwordReset": input.Password != ""}, "")
return s.user(ctx, id)
}
func (s *PortalService) ReplaceUserApps(ctx context.Context, userID uint64, request PortalUserAppRequest, actor string) ([]PortalUserApp, error) {
if len(request.Apps) > 100 {
return nil, fmt.Errorf("%w: app memberships exceed 100", ErrInvalidRequest)
}
seen := map[uint64]bool{}
for _, item := range request.Apps {
item.Role = strings.ToLower(strings.TrimSpace(item.Role))
if item.AppID == 0 || seen[item.AppID] || !validPortalRole(item.Role) {
return nil, fmt.Errorf("%w: invalid or duplicate app membership", ErrInvalidRequest)
}
seen[item.AppID] = true
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
var exists int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_open_user WHERE id=? FOR UPDATE`, userID).Scan(&exists); err != nil {
return nil, err
}
if exists == 0 {
return nil, ErrNotFound
}
for _, item := range request.Apps {
var appExists int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_open_app WHERE id=?`, item.AppID).Scan(&appExists); err != nil {
return nil, err
}
if appExists == 0 {
return nil, fmt.Errorf("%w: app %d", ErrInvalidRequest, item.AppID)
}
}
if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_user_app WHERE user_id=?`, userID); err != nil {
return nil, err
}
for _, item := range request.Apps {
if _, err := tx.ExecContext(ctx, `
INSERT INTO vehicle_open_user_app(user_id,app_id,role,granted_by) VALUES(?,?,?,?)`,
userID, item.AppID, strings.ToLower(strings.TrimSpace(item.Role)), actor); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
_ = s.userAudit(ctx, userID, actor, "user.apps.replace", "success", map[string]any{"count": len(request.Apps)}, "")
return s.ListUserApps(ctx, userID)
}
func (s *PortalService) ListUserApps(ctx context.Context, userID uint64) ([]PortalUserApp, error) {
return s.portalApps(ctx, userID)
}
func (s *PortalService) Login(ctx context.Context, input PortalLoginRequest, remoteAddr, userAgent string) (PortalLoginResponse, error) {
input.Username = strings.TrimSpace(input.Username)
if !portalUsernamePattern.MatchString(input.Username) || len(input.Password) > 128 {
return PortalLoginResponse{}, ErrUnauthorized
}
credential, err := s.loginCredential(ctx, input.Username)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
_ = s.userAudit(ctx, 0, input.Username, "login", "denied", map[string]any{"reason": "invalid_credentials"}, remoteAddr)
return PortalLoginResponse{}, ErrUnauthorized
}
return PortalLoginResponse{}, err
}
now := s.now()
if credential.Status != "enabled" ||
(credential.UserType != "admin" && (credential.ValidFrom.After(now) || (credential.ValidTo != nil && !credential.ValidTo.After(now)))) {
_ = s.userAudit(ctx, auditPortalUserID(credential), input.Username, "login", "denied", map[string]any{"reason": "disabled_or_expired"}, remoteAddr)
return PortalLoginResponse{}, ErrForbidden
}
if credential.LockedUntil.Valid && credential.LockedUntil.Time.After(now) {
return PortalLoginResponse{}, fmt.Errorf("%w: account locked", ErrForbidden)
}
if bcrypt.CompareHashAndPassword([]byte(credential.PasswordHash), []byte(input.Password)) != nil {
failures := credential.FailedLoginCount + 1
var lockedUntil any
if failures >= portalMaxLoginFailures {
lockedUntil = now.Add(portalLoginLock)
}
_, _ = s.db.ExecContext(ctx, `UPDATE `+credentialTable(credential.UserType)+` SET failed_login_count=?,locked_until=? WHERE id=?`, failures, lockedUntil, credential.ID)
_ = s.userAudit(ctx, auditPortalUserID(credential), input.Username, "login", "denied", map[string]any{"reason": "invalid_credentials", "failures": failures}, remoteAddr)
return PortalLoginResponse{}, ErrUnauthorized
}
rawToken, tokenHash, err := newPortalSessionToken()
if err != nil {
return PortalLoginResponse{}, err
}
sessionID, err := randomHexBytes(16)
if err != nil {
return PortalLoginResponse{}, err
}
expiresAt := now.Add(s.sessionTTL)
var portalUserID, platformUserID any
if credential.UserType == "admin" {
platformUserID = credential.ID
} else {
portalUserID = credential.ID
}
_, err = s.db.ExecContext(ctx, `
INSERT INTO vehicle_open_user_session(
id,user_id,platform_user_id,token_hash,issued_at,expires_at,last_seen_at,remote_addr,user_agent
) VALUES(?,?,?,?,?,?,?,?,?)`,
sessionID, portalUserID, platformUserID, tokenHash[:], now, expiresAt, now, truncate(remoteAddr, 96), truncate(userAgent, 255))
if err != nil {
return PortalLoginResponse{}, err
}
_, _ = s.db.ExecContext(ctx, `UPDATE `+credentialTable(credential.UserType)+` SET failed_login_count=0,locked_until=NULL,last_login_at=? WHERE id=?`, now, credential.ID)
_ = s.userAudit(ctx, auditPortalUserID(credential), credential.Username, "login", "success", map[string]any{"userType": credential.UserType}, remoteAddr)
session := PortalSession{UserID: credential.ID, Username: credential.Username, DisplayName: credential.DisplayName, UserType: credential.UserType, ExpiresAt: expiresAt}
return PortalLoginResponse{AccessToken: rawToken, ExpiresAt: expiresAt, Session: session}, nil
}
func (s *PortalService) Authenticate(ctx context.Context, rawToken string) (PortalSession, error) {
if len(rawToken) != 64 {
return PortalSession{}, ErrUnauthorized
}
hash := sha256.Sum256([]byte(rawToken))
var session PortalSession
err := s.db.QueryRowContext(ctx, `
SELECT COALESCE(u.id,p.id),COALESCE(u.username,p.username),COALESCE(u.display_name,p.display_name),
CASE WHEN p.id IS NOT NULL THEN 'admin' ELSE 'partner' END,se.expires_at
FROM vehicle_open_user_session se
LEFT JOIN vehicle_open_user u ON u.id=se.user_id
LEFT JOIN platform_user p ON p.id=se.platform_user_id
WHERE se.token_hash=?
AND se.revoked_at IS NULL
AND se.expires_at>NOW(3)
AND ((u.id IS NOT NULL AND u.status='enabled' AND u.valid_from<=NOW(3)
AND (u.valid_to IS NULL OR u.valid_to>NOW(3)))
OR (p.id IS NOT NULL AND p.user_type='admin' AND p.status='enabled'
AND p.auth_provider='local'))`, hash[:]).
Scan(&session.UserID, &session.Username, &session.DisplayName, &session.UserType, &session.ExpiresAt)
if errors.Is(err, sql.ErrNoRows) {
return PortalSession{}, ErrUnauthorized
}
if err != nil {
return PortalSession{}, err
}
_, _ = s.db.ExecContext(ctx, `UPDATE vehicle_open_user_session SET last_seen_at=NOW(3) WHERE token_hash=?`, hash[:])
return session, nil
}
func (s *PortalService) Logout(ctx context.Context, rawToken string) {
hash := sha256.Sum256([]byte(rawToken))
_, _ = s.db.ExecContext(ctx, `UPDATE vehicle_open_user_session SET revoked_at=NOW(3) WHERE token_hash=? AND revoked_at IS NULL`, hash[:])
}
func (s *PortalService) ChangePassword(ctx context.Context, session PortalSession, currentPassword, newPassword string) error {
if session.UserType == "admin" {
return fmt.Errorf("%w: platform administrators must change passwords on the internal platform", ErrInvalidRequest)
}
if err := validatePortalPassword(newPassword); err != nil {
return err
}
var currentHash string
table := credentialTable(session.UserType)
if err := s.db.QueryRowContext(ctx, `SELECT password_hash FROM `+table+` WHERE id=? AND status='enabled'`, session.UserID).Scan(&currentHash); err != nil {
return ErrUnauthorized
}
if bcrypt.CompareHashAndPassword([]byte(currentHash), []byte(currentPassword)) != nil {
return ErrUnauthorized
}
if bcrypt.CompareHashAndPassword([]byte(currentHash), []byte(newPassword)) == nil {
return fmt.Errorf("%w: password unchanged", ErrInvalidRequest)
}
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), 12)
if err != nil {
return err
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `UPDATE `+table+` SET password_hash=?,password_changed_at=NOW(3),updated_by=? WHERE id=?`, string(hash), session.Username, session.UserID); err != nil {
return err
}
identityColumn := "user_id"
if session.UserType == "admin" {
identityColumn = "platform_user_id"
if _, err := tx.ExecContext(ctx, `UPDATE platform_user_session SET revoked_at=NOW(3) WHERE user_id=? AND revoked_at IS NULL`, session.UserID); err != nil {
return err
}
}
if _, err := tx.ExecContext(ctx, `UPDATE vehicle_open_user_session SET revoked_at=NOW(3) WHERE `+identityColumn+`=? AND revoked_at IS NULL`, session.UserID); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
auditUserID := session.UserID
if session.UserType == "admin" {
auditUserID = 0
}
_ = s.userAudit(ctx, auditUserID, session.Username, "password.change", "success", map[string]any{"userType": session.UserType}, "")
return nil
}
func (s *PortalService) Apps(ctx context.Context, session PortalSession) ([]PortalUserApp, error) {
if session.UserType == "admin" {
return s.adminApps(ctx)
}
return s.portalApps(ctx, session.UserID)
}
func (s *PortalService) Vehicles(ctx context.Context, session PortalSession, appID uint64) ([]VehicleGrant, error) {
if _, err := s.MembershipRole(ctx, session, appID); err != nil {
return nil, err
}
rows, err := s.db.QueryContext(ctx, `
SELECT g.vin,COALESCE(MAX(b.plate),''),g.valid_from,g.valid_to,g.granted_by,g.updated_at
FROM vehicle_open_app_vehicle g
LEFT JOIN vehicle_identity_binding b ON BINARY b.vin=BINARY g.vin
WHERE g.app_id=?
GROUP BY g.app_id,g.vin,g.valid_from,g.valid_to,g.granted_by,g.updated_at
ORDER BY g.vin`, appID)
if err != nil {
return nil, err
}
defer rows.Close()
grants := make([]VehicleGrant, 0)
for rows.Next() {
var grant VehicleGrant
var validTo sql.NullTime
if err := rows.Scan(&grant.VIN, &grant.Plate, &grant.ValidFrom, &validTo, &grant.GrantedBy, &grant.UpdatedAt); err != nil {
return nil, err
}
if validTo.Valid {
grant.ValidTo = &validTo.Time
}
grants = append(grants, grant)
}
return grants, rows.Err()
}
func (s *PortalService) VehicleCatalog(ctx context.Context) ([]VehicleCatalogItem, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT b.vin,
COALESCE(MAX(NULLIF(b.plate,'')),''),
COALESCE(MAX(NULLIF(b.oem,'')),'')
FROM vehicle_identity_binding b
WHERE b.vin IS NOT NULL AND b.vin<>''
GROUP BY b.vin
ORDER BY COALESCE(MAX(NULLIF(b.plate,'')),''),b.vin`)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]VehicleCatalogItem, 0)
for rows.Next() {
var item VehicleCatalogItem
if err := rows.Scan(&item.VIN, &item.Plate, &item.OEM); err != nil {
return nil, err
}
item.Status = "available"
item.Source = "车辆主数据"
items = append(items, item)
}
return items, rows.Err()
}
func (s *PortalService) Audit(ctx context.Context, session PortalSession, appID uint64) ([]PortalAuditItem, error) {
if _, err := s.MembershipRole(ctx, session, appID); err != nil {
return nil, err
}
rows, err := s.db.QueryContext(ctx, `
SELECT trace_id,endpoint,result,requested_vehicle_count,created_at
FROM vehicle_open_api_audit WHERE app_id=? ORDER BY id DESC LIMIT 100`, appID)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]PortalAuditItem, 0)
for rows.Next() {
var item PortalAuditItem
if err := rows.Scan(&item.TraceID, &item.Endpoint, &item.Result, &item.VehicleCount, &item.CreatedAt); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *PortalService) CanRotateKey(ctx context.Context, session PortalSession, appID uint64) error {
role, err := s.MembershipRole(ctx, session, appID)
if err != nil {
return err
}
if role != "owner" {
return ErrForbidden
}
return nil
}
func (s *PortalService) user(ctx context.Context, id uint64) (PortalUser, error) {
row := s.db.QueryRowContext(ctx, `
SELECT id,username,display_name,status,valid_from,valid_to,last_login_at,created_by,created_at,updated_at
FROM vehicle_open_user WHERE id=?`, id)
user, err := scanPortalUser(row)
if errors.Is(err, sql.ErrNoRows) {
return PortalUser{}, ErrNotFound
}
return user, err
}
func (s *PortalService) credential(ctx context.Context, username string) (portalCredential, error) {
var credential portalCredential
var validTo, lastLogin, locked sql.NullTime
err := s.db.QueryRowContext(ctx, `
SELECT id,username,display_name,password_hash,status,valid_from,valid_to,failed_login_count,locked_until,last_login_at,created_by,created_at,updated_at
FROM vehicle_open_user WHERE username=?`, username).Scan(
&credential.ID, &credential.Username, &credential.DisplayName, &credential.PasswordHash,
&credential.Status, &credential.ValidFrom, &validTo, &credential.FailedLoginCount,
&locked, &lastLogin, &credential.CreatedBy, &credential.CreatedAt, &credential.UpdatedAt,
)
if validTo.Valid {
credential.ValidTo = &validTo.Time
}
if lastLogin.Valid {
credential.LastLoginAt = &lastLogin.Time
}
credential.LockedUntil = locked
credential.UserType = "partner"
return credential, err
}
func (s *PortalService) loginCredential(ctx context.Context, username string) (portalCredential, error) {
credential, err := s.credential(ctx, username)
if err == nil || !errors.Is(err, sql.ErrNoRows) {
return credential, err
}
var admin portalCredential
var lastLogin, locked sql.NullTime
err = s.db.QueryRowContext(ctx, `
SELECT id,username,display_name,password_hash,status,failed_login_count,locked_until,last_login_at,created_by,created_at,updated_at
FROM platform_user
WHERE username=? AND user_type='admin' AND auth_provider='local'`, username).Scan(
&admin.ID, &admin.Username, &admin.DisplayName, &admin.PasswordHash,
&admin.Status, &admin.FailedLoginCount, &locked, &lastLogin,
&admin.CreatedBy, &admin.CreatedAt, &admin.UpdatedAt,
)
if lastLogin.Valid {
admin.LastLoginAt = &lastLogin.Time
}
admin.LockedUntil = locked
admin.UserType = "admin"
return admin, err
}
func (s *PortalService) platformAdminUsername(ctx context.Context, username string) (bool, error) {
var count int
err := s.db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM platform_user
WHERE username=? AND user_type='admin' AND auth_provider='local'`, username).Scan(&count)
return count > 0, err
}
func (s *PortalService) portalApps(ctx context.Context, userID uint64) ([]PortalUserApp, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT a.id,a.name,a.app_key_prefix,a.status,ua.role,a.valid_from,a.valid_to
FROM vehicle_open_user_app ua
JOIN vehicle_open_app a ON a.id=ua.app_id
WHERE ua.user_id=? ORDER BY a.id DESC`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
apps := make([]PortalUserApp, 0)
for rows.Next() {
var app PortalUserApp
var validTo sql.NullTime
if err := rows.Scan(&app.AppID, &app.AppName, &app.AppKeyPrefix, &app.AppStatus, &app.Role, &app.ValidFrom, &validTo); err != nil {
return nil, err
}
if validTo.Valid {
app.ValidTo = &validTo.Time
}
apps = append(apps, app)
}
return apps, rows.Err()
}
func (s *PortalService) adminApps(ctx context.Context) ([]PortalUserApp, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id,name,app_key_prefix,status,'owner',valid_from,valid_to
FROM vehicle_open_app ORDER BY id DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
apps := make([]PortalUserApp, 0)
for rows.Next() {
var app PortalUserApp
var validTo sql.NullTime
if err := rows.Scan(&app.AppID, &app.AppName, &app.AppKeyPrefix, &app.AppStatus, &app.Role, &app.ValidFrom, &validTo); err != nil {
return nil, err
}
if validTo.Valid {
app.ValidTo = &validTo.Time
}
apps = append(apps, app)
}
return apps, rows.Err()
}
func (s *PortalService) membershipRole(ctx context.Context, userID, appID uint64) (string, error) {
var role string
err := s.db.QueryRowContext(ctx, `SELECT role FROM vehicle_open_user_app WHERE user_id=? AND app_id=?`, userID, appID).Scan(&role)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrForbidden
}
return role, err
}
func (s *PortalService) MembershipRole(ctx context.Context, session PortalSession, appID uint64) (string, error) {
if session.UserType == "admin" {
var exists int
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_open_app WHERE id=?`, appID).Scan(&exists); err != nil {
return "", err
}
if exists == 0 {
return "", ErrNotFound
}
return "owner", nil
}
return s.membershipRole(ctx, session.UserID, appID)
}
func (s *PortalService) userAudit(ctx context.Context, userID uint64, actor, action, result string, detail any, remoteAddr string) error {
var nullableUser any
if userID > 0 {
nullableUser = userID
}
var encoded any
if detail != nil {
value, _ := json.Marshal(detail)
encoded = string(value)
}
_, err := s.db.ExecContext(ctx, `
INSERT INTO vehicle_open_user_audit(user_id,actor,action,result,detail_json,remote_addr)
VALUES(?,?,?,?,?,?)`, nullableUser, truncate(actor, 96), action, result, encoded, truncate(remoteAddr, 96))
return err
}
func scanPortalUser(row scanner) (PortalUser, error) {
var user PortalUser
var validTo, lastLogin sql.NullTime
err := row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Status, &user.ValidFrom, &validTo, &lastLogin, &user.CreatedBy, &user.CreatedAt, &user.UpdatedAt)
if validTo.Valid {
user.ValidTo = &validTo.Time
}
if lastLogin.Valid {
user.LastLoginAt = &lastLogin.Time
}
return user, err
}
func validatePortalUserInput(input *PortalUserInput, passwordRequired bool) (time.Time, *time.Time, error) {
input.Username = strings.TrimSpace(input.Username)
input.DisplayName = strings.TrimSpace(input.DisplayName)
input.Status = strings.ToLower(strings.TrimSpace(input.Status))
if input.Status == "" {
input.Status = "enabled"
}
if !portalUsernamePattern.MatchString(input.Username) || input.DisplayName == "" || len([]rune(input.DisplayName)) > 96 {
return time.Time{}, nil, fmt.Errorf("%w: invalid portal user", ErrInvalidRequest)
}
if input.Status != "enabled" && input.Status != "disabled" {
return time.Time{}, nil, fmt.Errorf("%w: invalid user status", ErrInvalidRequest)
}
if passwordRequired || input.Password != "" {
if err := validatePortalPassword(input.Password); err != nil {
return time.Time{}, nil, err
}
}
return parseInterval(input.ValidFrom, input.ValidTo)
}
func validatePortalPassword(password string) error {
if len(password) < 12 || len(password) > 128 {
return fmt.Errorf("%w: password must be 12-128 characters", ErrInvalidRequest)
}
var lower, upper, digit bool
for _, value := range password {
switch {
case value >= 'a' && value <= 'z':
lower = true
case value >= 'A' && value <= 'Z':
upper = true
case value >= '0' && value <= '9':
digit = true
}
}
if !lower || !upper || !digit {
return fmt.Errorf("%w: password requires upper, lower and digit", ErrInvalidRequest)
}
return nil
}
func validPortalRole(role string) bool {
return role == "owner" || role == "developer" || role == "viewer"
}
func credentialTable(userType string) string {
if userType == "admin" {
return "platform_user"
}
return "vehicle_open_user"
}
func auditPortalUserID(credential portalCredential) uint64 {
if credential.UserType == "admin" {
return 0
}
return credential.ID
}
func newPortalSessionToken() (string, [sha256.Size]byte, error) {
raw, err := randomHexBytes(32)
if err != nil {
return "", [sha256.Size]byte{}, err
}
return raw, sha256.Sum256([]byte(raw)), nil
}
func randomHexBytes(size int) (string, error) {
value := make([]byte, size)
if _, err := rand.Read(value); err != nil {
return "", err
}
return hex.EncodeToString(value), nil
}