5
pkg/usrmgr/coredata/migrations/20250225T153220Z.sql
Normal file
5
pkg/usrmgr/coredata/migrations/20250225T153220Z.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- Add organization_id column to usrmgr_users table
|
||||
ALTER TABLE usrmgr_users ADD COLUMN organization_id TEXT REFERENCES organizations(id);
|
||||
|
||||
-- Create an index for faster lookups
|
||||
CREATE INDEX usrmgr_users_organization_id_idx ON usrmgr_users(organization_id);
|
||||
@@ -16,7 +16,6 @@ package coredata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
@@ -57,17 +56,17 @@ func (s *Session) LoadByID(
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
user_id,
|
||||
expired_at,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
sessions
|
||||
usrmgr_sessions
|
||||
WHERE
|
||||
id = @session_id
|
||||
LIMIT 1;
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q)
|
||||
|
||||
args := pgx.NamedArgs{"session_id": sessionID}
|
||||
|
||||
r := conn.QueryRow(ctx, q, args)
|
||||
@@ -88,7 +87,7 @@ func (s *Session) Insert(
|
||||
) error {
|
||||
q := `
|
||||
INSERT INTO
|
||||
sessions (id, user_id, expired_at, created_at, updated_at)
|
||||
usrmgr_sessions (id, user_id, expired_at, created_at, updated_at)
|
||||
VALUES (
|
||||
@session_id,
|
||||
@user_id,
|
||||
@@ -109,3 +108,44 @@ VALUES (
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Session) Update(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
) error {
|
||||
q := `
|
||||
UPDATE usrmgr_sessions
|
||||
SET
|
||||
expired_at = @expired_at,
|
||||
updated_at = @updated_at
|
||||
WHERE
|
||||
id = @session_id
|
||||
`
|
||||
|
||||
args := pgx.NamedArgs{
|
||||
"session_id": s.ID,
|
||||
"expired_at": s.ExpiredAt,
|
||||
"updated_at": s.UpdatedAt,
|
||||
}
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteSession(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
sessionID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
DELETE FROM
|
||||
usrmgr_sessions
|
||||
WHERE
|
||||
id = @session_id
|
||||
`
|
||||
|
||||
args := pgx.NamedArgs{"session_id": sessionID}
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ type (
|
||||
ID gid.GID
|
||||
EmailAddress string
|
||||
HashedPassword []byte
|
||||
OrganizationID gid.GID
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
@@ -43,6 +44,7 @@ func (u *User) scan(r pgx.Row) error {
|
||||
&u.ID,
|
||||
&u.EmailAddress,
|
||||
&u.HashedPassword,
|
||||
&u.OrganizationID,
|
||||
&u.CreatedAt,
|
||||
&u.UpdatedAt,
|
||||
)
|
||||
@@ -58,12 +60,13 @@ SELECT
|
||||
id,
|
||||
email_address,
|
||||
hashed_password,
|
||||
organization_id,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
users
|
||||
usrmgr_users
|
||||
WHERE
|
||||
email = @user_email
|
||||
email_address = @user_email
|
||||
LIMIT 1;
|
||||
`
|
||||
|
||||
@@ -80,3 +83,67 @@ LIMIT 1;
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *User) LoadByID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
userID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
email_address,
|
||||
hashed_password,
|
||||
organization_id,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
usrmgr_users
|
||||
WHERE
|
||||
id = @user_id
|
||||
LIMIT 1;
|
||||
`
|
||||
|
||||
args := pgx.NamedArgs{"user_id": userID}
|
||||
|
||||
r := conn.QueryRow(ctx, q, args)
|
||||
|
||||
u2 := User{}
|
||||
if err := u2.scan(r); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*u = u2
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *User) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
) error {
|
||||
q := `
|
||||
INSERT INTO
|
||||
usrmgr_users (id, email_address, hashed_password, organization_id, created_at, updated_at)
|
||||
VALUES (
|
||||
@user_id,
|
||||
@email_address,
|
||||
@hashed_password,
|
||||
@organization_id,
|
||||
@created_at,
|
||||
@updated_at
|
||||
)
|
||||
`
|
||||
|
||||
args := pgx.NamedArgs{
|
||||
"user_id": u.ID,
|
||||
"email_address": u.EmailAddress,
|
||||
"hashed_password": u.HashedPassword,
|
||||
"organization_id": "AZSfP_xAcAC5IAAAAAAltA",
|
||||
"created_at": u.CreatedAt,
|
||||
"updated_at": u.UpdatedAt,
|
||||
}
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
"github.com/getprobo/probo/pkg/usrmgr/coredata"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.gearno.de/kit/migrator"
|
||||
"go.gearno.de/kit/pg"
|
||||
)
|
||||
@@ -30,22 +31,116 @@ type (
|
||||
pg *pg.Client
|
||||
hp *HashingProfile
|
||||
}
|
||||
|
||||
RegisterUserParams struct {
|
||||
Email string
|
||||
Password string
|
||||
}
|
||||
|
||||
ErrInvalidCredentials struct {
|
||||
message string
|
||||
}
|
||||
|
||||
ErrUserAlreadyExists struct {
|
||||
message string
|
||||
}
|
||||
|
||||
ErrSessionNotFound struct {
|
||||
message string
|
||||
}
|
||||
|
||||
ErrSessionExpired struct {
|
||||
message string
|
||||
}
|
||||
)
|
||||
|
||||
func (e ErrInvalidCredentials) Error() string {
|
||||
return e.message
|
||||
}
|
||||
|
||||
func (e ErrUserAlreadyExists) Error() string {
|
||||
return e.message
|
||||
}
|
||||
|
||||
func (e ErrSessionNotFound) Error() string {
|
||||
return e.message
|
||||
}
|
||||
|
||||
func (e ErrSessionExpired) Error() string {
|
||||
return e.message
|
||||
}
|
||||
|
||||
func NewService(
|
||||
ctx context.Context,
|
||||
pgClient *pg.Client,
|
||||
pepper []byte,
|
||||
) (*Service, error) {
|
||||
err := migrator.NewMigrator(pgClient, coredata.Migrations).Run(ctx, "migrations")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot migrate database schema: %w", err)
|
||||
}
|
||||
|
||||
hp, err := NewHashingProfile(pepper)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create hashing profile: %w", err)
|
||||
}
|
||||
|
||||
return &Service{
|
||||
pg: pgClient,
|
||||
hp: hp,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s Service) RegisterUser(
|
||||
ctx context.Context,
|
||||
params RegisterUserParams,
|
||||
) (*coredata.User, error) {
|
||||
if params.Email == "" || params.Password == "" {
|
||||
return nil, fmt.Errorf("email and password are required")
|
||||
}
|
||||
|
||||
// Use a high iteration count for password hashing
|
||||
const iterations = 600000
|
||||
hashedPassword, err := s.hp.HashPassword([]byte(params.Password), iterations)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot hash password: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
user := &coredata.User{
|
||||
ID: gid.New(),
|
||||
EmailAddress: params.Email,
|
||||
HashedPassword: hashedPassword,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
err = s.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
// Check if user already exists
|
||||
existingUser := &coredata.User{}
|
||||
err := existingUser.LoadByEmail(ctx, tx, params.Email)
|
||||
if err == nil {
|
||||
return &ErrUserAlreadyExists{message: "user with this email already exists"}
|
||||
}
|
||||
|
||||
// Insert the new user
|
||||
if err := user.Insert(ctx, tx); err != nil {
|
||||
return fmt.Errorf("cannot insert user: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s Service) Login(
|
||||
ctx context.Context,
|
||||
email string,
|
||||
@@ -54,8 +149,8 @@ func (s Service) Login(
|
||||
now := time.Now()
|
||||
user := &coredata.User{}
|
||||
session := &coredata.Session{
|
||||
ID: gid.GID{},
|
||||
UserID: user.ID,
|
||||
ID: gid.New(),
|
||||
UserID: gid.GID{}, // Will be set after user is loaded
|
||||
ExpiredAt: now.Add(24 * time.Hour),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
@@ -65,18 +160,21 @@ func (s Service) Login(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
if err := user.LoadByEmail(ctx, tx, email); err != nil {
|
||||
return fmt.Errorf("cannot load user by email: %w", err)
|
||||
return &ErrInvalidCredentials{message: "invalid email or password"}
|
||||
}
|
||||
|
||||
ok, err := s.hp.ComparePasswordAndHash([]byte(password), user.HashedPassword)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot constant compare byte: %w", err)
|
||||
return fmt.Errorf("cannot compare password: %w", err)
|
||||
}
|
||||
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid password")
|
||||
return &ErrInvalidCredentials{message: "invalid email or password"}
|
||||
}
|
||||
|
||||
// Set the user ID in the session
|
||||
session.UserID = user.ID
|
||||
|
||||
if err := session.Insert(ctx, tx); err != nil {
|
||||
return fmt.Errorf("cannot insert session: %w", err)
|
||||
}
|
||||
@@ -89,13 +187,176 @@ func (s Service) Login(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (s Service) Logout(sessionID string) error {
|
||||
return nil
|
||||
func (s Service) Logout(
|
||||
ctx context.Context,
|
||||
sessionID gid.GID,
|
||||
) error {
|
||||
return s.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
return coredata.DeleteSession(ctx, tx, sessionID)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (s Service) GetSession(sessionID string) (*coredata.Session, error) {
|
||||
return nil, nil
|
||||
func (s Service) GetSession(
|
||||
ctx context.Context,
|
||||
sessionID gid.GID,
|
||||
) (*coredata.Session, error) {
|
||||
session := &coredata.Session{}
|
||||
|
||||
err := s.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
if err := session.LoadByID(ctx, tx, sessionID); err != nil {
|
||||
return &ErrSessionNotFound{message: "session not found"}
|
||||
}
|
||||
|
||||
// Check if session is expired
|
||||
if time.Now().After(session.ExpiredAt) {
|
||||
// Delete expired session
|
||||
if err := coredata.DeleteSession(ctx, tx, sessionID); err != nil {
|
||||
return fmt.Errorf("cannot delete expired session: %w", err)
|
||||
}
|
||||
return &ErrSessionExpired{message: "session expired"}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (s Service) RefreshSession(
|
||||
ctx context.Context,
|
||||
sessionID gid.GID,
|
||||
) (*coredata.Session, error) {
|
||||
session := &coredata.Session{}
|
||||
|
||||
err := s.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
if err := session.LoadByID(ctx, tx, sessionID); err != nil {
|
||||
return &ErrSessionNotFound{message: "session not found"}
|
||||
}
|
||||
|
||||
// Check if session is expired
|
||||
if time.Now().After(session.ExpiredAt) {
|
||||
return &ErrSessionExpired{message: "session expired"}
|
||||
}
|
||||
|
||||
// Update session expiration
|
||||
now := time.Now()
|
||||
session.ExpiredAt = now.Add(24 * time.Hour)
|
||||
session.UpdatedAt = now
|
||||
|
||||
if err := session.Update(ctx, tx); err != nil {
|
||||
return fmt.Errorf("cannot update session: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (s Service) GetUserByID(
|
||||
ctx context.Context,
|
||||
userID gid.GID,
|
||||
) (*coredata.User, error) {
|
||||
user := &coredata.User{}
|
||||
|
||||
err := s.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
if err := user.LoadByID(ctx, tx, userID); err != nil {
|
||||
return fmt.Errorf("user not found: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s Service) GetUserBySession(
|
||||
ctx context.Context,
|
||||
sessionID gid.GID,
|
||||
) (*coredata.User, error) {
|
||||
session, err := s.GetSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.GetUserByID(ctx, session.UserID)
|
||||
}
|
||||
|
||||
// SetUserOrganization sets the organization for a user
|
||||
func (s Service) SetUserOrganization(
|
||||
ctx context.Context,
|
||||
userID gid.GID,
|
||||
organizationID gid.GID,
|
||||
) error {
|
||||
return s.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
user := &coredata.User{}
|
||||
if err := user.LoadByID(ctx, tx, userID); err != nil {
|
||||
return fmt.Errorf("user not found: %w", err)
|
||||
}
|
||||
|
||||
// Update the organization ID
|
||||
user.OrganizationID = organizationID
|
||||
user.UpdatedAt = time.Now()
|
||||
|
||||
// Update the user in the database
|
||||
q := `
|
||||
UPDATE usrmgr_users
|
||||
SET organization_id = @organization_id, updated_at = @updated_at
|
||||
WHERE id = @user_id
|
||||
`
|
||||
args := pgx.NamedArgs{
|
||||
"user_id": user.ID,
|
||||
"organization_id": user.OrganizationID,
|
||||
"updated_at": user.UpdatedAt,
|
||||
}
|
||||
|
||||
_, err := tx.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot update user organization: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// GetUserOrganization gets the organization ID for a user
|
||||
func (s Service) GetUserOrganization(
|
||||
ctx context.Context,
|
||||
userID gid.GID,
|
||||
) (gid.GID, error) {
|
||||
user, err := s.GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
return gid.GID{}, err
|
||||
}
|
||||
|
||||
return user.OrganizationID, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user