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(¤tHash); 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 }