Move coredata outside probo service

Signed-off-by: gearnode <bryan@frimin.fr>
This commit is contained in:
gearnode
2025-03-11 09:38:36 +01:00
parent 3c1a0c9d6d
commit 5bdc474aef
113 changed files with 119 additions and 166 deletions

View File

@@ -1,24 +0,0 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package coredata
import (
"embed"
)
var (
//go:embed migrations/*.sql
Migrations embed.FS
)

View File

@@ -1,12 +0,0 @@
CREATE TABLE usrmgr_users (
id TEXT PRIMARY KEY,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE usrmgr_sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES usrmgr_users(id),
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);

View File

@@ -1,5 +0,0 @@
CREATE EXTENSION citext;
ALTER TABLE usrmgr_users
ADD COLUMN email_address CITEXT NOT NULL,
ADD COLUMN hashed_password BYTEA NOT NULL;

View File

@@ -1 +0,0 @@
ALTER TABLE usrmgr_users ADD UNIQUE (email_address);

View File

@@ -1 +0,0 @@
ALTER TABLE usrmgr_sessions ADD COLUMN expired_at TIMESTAMP WITH TIME ZONE NOT NULL;

View File

@@ -1,3 +0,0 @@
ALTER TABLE usrmgr_users ADD COLUMN organization_id TEXT;
CREATE INDEX usrmgr_users_organization_id_idx ON usrmgr_users(organization_id);

View File

@@ -1,11 +0,0 @@
CREATE TABLE usrmgr_user_organizations (
user_id TEXT REFERENCES usrmgr_users(id) NOT NULL,
organization_id TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, organization_id)
);
INSERT INTO usrmgr_user_organizations (user_id, organization_id, created_at)
SELECT id, organization_id, NOW()
FROM usrmgr_users
WHERE organization_id IS NOT NULL;

View File

@@ -1 +0,0 @@
ALTER TABLE usrmgr_users ADD COLUMN fullname TEXT;

View File

@@ -1 +0,0 @@
ALTER TABLE usrmgr_users ALTER COLUMN fullname SET NOT NULL;

View File

@@ -1 +0,0 @@
ALTER TABLE usrmgr_sessions ADD COLUMN data jsonb NOT NULL DEFAULT '{}';

View File

@@ -1,152 +0,0 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package coredata
import (
"context"
"fmt"
"time"
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/page"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
)
type (
Session struct {
ID gid.GID `db:"id"`
UserID gid.GID `db:"user_id"`
Data SessionData `db:"data"`
ExpiredAt time.Time `db:"expired_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
SessionData struct{}
)
func (s Session) CursorKey() page.CursorKey {
return page.NewCursorKey(s.ID, s.CreatedAt)
}
func (s *Session) LoadByID(
ctx context.Context,
conn pg.Conn,
sessionID gid.GID,
) error {
q := `
SELECT
id,
user_id,
data,
expired_at,
created_at,
updated_at
FROM
usrmgr_sessions
WHERE
id = @session_id
LIMIT 1;
`
args := pgx.StrictNamedArgs{"session_id": sessionID}
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query session: %w", err)
}
session, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Session])
if err != nil {
return fmt.Errorf("cannot collect session: %w", err)
}
*s = session
return nil
}
func (s *Session) Insert(
ctx context.Context,
conn pg.Conn,
) error {
q := `
INSERT INTO
usrmgr_sessions (id, user_id, data, expired_at, created_at, updated_at)
VALUES (
@session_id,
@user_id,
@data,
@expired_at,
@created_at,
@updated_at
)
`
args := pgx.StrictNamedArgs{
"session_id": s.ID,
"user_id": s.UserID,
"data": s.Data,
"expired_at": s.ExpiredAt,
"created_at": s.CreatedAt,
"updated_at": s.UpdatedAt,
}
_, 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,
data = @data
WHERE
id = @session_id
`
args := pgx.StrictNamedArgs{
"session_id": s.ID,
"user_id": s.UserID,
"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.StrictNamedArgs{"session_id": sessionID}
_, err := conn.Exec(ctx, q, args)
return err
}

View File

@@ -1,145 +0,0 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package coredata
import (
"context"
"fmt"
"time"
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/page"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
)
type (
User struct {
ID gid.GID `db:"id"`
EmailAddress string `db:"email_address"`
HashedPassword []byte `db:"hashed_password"`
FullName string `db:"fullname"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
)
func (u User) CursorKey() page.CursorKey {
return page.NewCursorKey(u.ID, u.CreatedAt)
}
func (u *User) LoadByEmail(
ctx context.Context,
conn pg.Conn,
email string,
) error {
q := `
SELECT
id,
email_address,
hashed_password,
fullname,
created_at,
updated_at
FROM
usrmgr_users
WHERE
email_address = @user_email
LIMIT 1;
`
args := pgx.StrictNamedArgs{"user_email": email}
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query user: %w", err)
}
user, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[User])
if err != nil {
return fmt.Errorf("cannot collect user: %w", err)
}
*u = user
return nil
}
func (u *User) LoadByID(
ctx context.Context,
conn pg.Conn,
userID gid.GID,
) error {
q := `
SELECT
id,
email_address,
hashed_password,
fullname,
created_at,
updated_at
FROM
usrmgr_users
WHERE
id = @user_id
LIMIT 1;
`
args := pgx.StrictNamedArgs{"user_id": userID}
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query user: %w", err)
}
user, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[User])
if err != nil {
return fmt.Errorf("cannot collect user: %w", err)
}
*u = user
return nil
}
func (u *User) Insert(
ctx context.Context,
conn pg.Conn,
) error {
q := `
INSERT INTO
usrmgr_users (id, email_address, hashed_password, fullname, created_at, updated_at)
VALUES (
@user_id,
@email_address,
@hashed_password,
@fullname,
@created_at,
@updated_at
)
`
args := pgx.StrictNamedArgs{
"user_id": u.ID,
"email_address": u.EmailAddress,
"hashed_password": u.HashedPassword,
"fullname": u.FullName,
"created_at": u.CreatedAt,
"updated_at": u.UpdatedAt,
}
_, err := conn.Exec(ctx, q, args)
return err
}

View File

@@ -1,135 +0,0 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package usrmgr
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/binary"
"fmt"
"golang.org/x/crypto/pbkdf2"
)
type (
HashingProfile struct {
minIterations uint
saltLength uint
keyLength uint
pepper []byte
}
)
const (
versionByte = 0x01 // Version identifier
algorithmByte = 0x01 // Algorithm identifier (0x01 for PBKDF2-SHA256)
)
func NewHashingProfile(pepper []byte) (*HashingProfile, error) {
if len(pepper) < 32 {
return nil, fmt.Errorf("pepper must be at least 32 bytes")
}
// NIST SP 800-63B recommendations:
// - At least 32 bits of salt (we use 256 bits/32 bytes for extra security)
// - At least 1000 iterations (we use higher based on processing capabilities)
// - Resulting key length should be at least 160 bits (we use 256 bits)
return &HashingProfile{
minIterations: 600000, // Minimum iterations (adjusted based on hardware speed)
saltLength: 32, // Salt length in bytes (256 bits)
keyLength: 32, // Output key length in bytes (256 bits)
pepper: pepper, // Pepper length in bytes (256 bits)
}, nil
}
func (hp HashingProfile) applyPepper(input []byte) []byte {
mac := hmac.New(sha256.New, hp.pepper)
mac.Write(input)
return mac.Sum(nil)
}
func (hp HashingProfile) HashPassword(password []byte, iterations uint32) ([]byte, error) {
salt := make([]byte, hp.saltLength)
if _, err := rand.Read(salt); err != nil {
return nil, fmt.Errorf("error generating salt: %v", err)
}
pepperedPassword := hp.applyPepper([]byte(password))
hash := pbkdf2.Key(pepperedPassword, salt, int(iterations), int(hp.keyLength), sha256.New)
// Binary format:
// [1B version][1B algorithm][4B iterations][1B salt length][salt bytes][hash bytes]
binaryHash := make([]byte, 0, 7+hp.saltLength+hp.keyLength)
// Version and algorithm
binaryHash = append(binaryHash, versionByte)
binaryHash = append(binaryHash, algorithmByte)
// Iterations (4 bytes, big endian)
iterBytes := make([]byte, 4)
binary.BigEndian.PutUint32(iterBytes, iterations)
binaryHash = append(binaryHash, iterBytes...)
// Salt length and salt
binaryHash = append(binaryHash, byte(hp.saltLength))
binaryHash = append(binaryHash, salt...)
// Hash
binaryHash = append(binaryHash, hash...)
return binaryHash, nil
}
func (hp HashingProfile) ComparePasswordAndHash(password, passwordHash []byte) (bool, error) {
if len(passwordHash) < 7 {
return false, fmt.Errorf("hash too short")
}
if passwordHash[0] != versionByte {
return false, fmt.Errorf("unsupported hash version: %d", passwordHash[0])
}
if passwordHash[1] != algorithmByte {
return false, fmt.Errorf("unsupported algorithm: %d", passwordHash[1])
}
// Extract iterations
iterations := binary.BigEndian.Uint32(passwordHash[2:6])
if iterations < uint32(hp.minIterations) {
return false, fmt.Errorf("iterations below minimum security threshold")
}
// Extract salt length and validate
saltLen := int(passwordHash[6])
if saltLen < 32 { // NIST minimum requirement
return false, fmt.Errorf("salt length below security minimum")
}
if len(passwordHash) < 7+saltLen+int(hp.keyLength) {
return false, fmt.Errorf("invalid hash length")
}
salt := passwordHash[7 : 7+saltLen]
storedHash := passwordHash[7+saltLen:]
pepperedPassword := hp.applyPepper([]byte(password))
newHash := pbkdf2.Key(pepperedPassword, salt, int(iterations), len(storedHash), sha256.New)
return subtle.ConstantTimeCompare(storedHash, newHash) == 1, nil
}

View File

@@ -19,17 +19,17 @@ import (
"fmt"
"time"
"github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/crypto/passwdhash"
"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"
)
type (
Service struct {
pg *pg.Client
hp *HashingProfile
hp *passwdhash.Profile
}
RegisterUserParams struct {
@@ -76,12 +76,7 @@ func NewService(
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)
hp, err := passwdhash.NewProfile(pepper)
if err != nil {
return nil, fmt.Errorf("cannot create hashing profile: %w", err)
}
@@ -109,7 +104,7 @@ func (s Service) RegisterUser(
now := time.Now()
user := &coredata.User{
ID: gid.New(gid.NilTenant, 0),
ID: gid.New(gid.NilTenant, coredata.UserEntityType),
EmailAddress: params.Email,
HashedPassword: hashedPassword,
FullName: params.FullName,
@@ -151,7 +146,7 @@ func (s Service) Login(
now := time.Now()
user := &coredata.User{}
session := &coredata.Session{
ID: gid.New(gid.NilTenant, 0),
ID: gid.New(gid.NilTenant, coredata.SessionEntityType),
UserID: gid.Nil,
ExpiredAt: now.Add(24 * time.Hour),
CreatedAt: now,