renommage lassistanoque

This commit is contained in:
2026-08-07 00:03:15 +02:00
commit 5810c4c94f
73 changed files with 2601 additions and 0 deletions
@@ -0,0 +1,130 @@
package password
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"strings"
"golang.org/x/crypto/argon2"
)
type params struct {
memory uint32 // en KiB
iterations uint32
parallelism uint8
saltLength uint32
keyLength uint32
}
var defaultParams = &params{
memory: 64 * 1024, // 64 MB
iterations: 3,
parallelism: 2,
saltLength: 16,
keyLength: 32,
}
// generateSalt crée un salt aléatoire cryptographiquement sûr
func generateSalt(length uint32) ([]byte, error) {
salt := make([]byte, length)
if _, err := rand.Read(salt); err != nil {
return nil, err
}
return salt, nil
}
// HashPassword génère un hash Argon2id encodé (salt + hash + paramètres inclus)
func HashPassword(password string) (string, error) {
salt, err := generateSalt(defaultParams.saltLength)
if err != nil {
return "", err
}
hash := argon2.IDKey(
[]byte(password),
salt,
defaultParams.iterations,
defaultParams.memory,
defaultParams.parallelism,
defaultParams.keyLength,
)
// Format standard: $argon2id$v=19$m=65536,t=3,p=2$salt$hash
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
encoded := fmt.Sprintf(
"$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version,
defaultParams.memory,
defaultParams.iterations,
defaultParams.parallelism,
b64Salt,
b64Hash,
)
return encoded, nil
}
// VerifyPassword compare un mot de passe en clair avec un hash stocké
func VerifyPassword(password, encodedHash string) (bool, error) {
p, salt, hash, err := decodeHash(encodedHash)
if err != nil {
return false, err
}
otherHash := argon2.IDKey(
[]byte(password),
salt,
p.iterations,
p.memory,
p.parallelism,
p.keyLength,
)
// Comparaison en temps constant pour éviter les timing attacks
if subtle.ConstantTimeCompare(hash, otherHash) == 1 {
return true, nil
}
return false, nil
}
// decodeHash parse la chaîne encodée pour en extraire params, salt et hash
func decodeHash(encodedHash string) (*params, []byte, []byte, error) {
parts := strings.Split(encodedHash, "$")
if len(parts) != 6 {
return nil, nil, nil, errors.New("format de hash invalide")
}
var version int
_, err := fmt.Sscanf(parts[2], "v=%d", &version)
if err != nil {
return nil, nil, nil, err
}
if version != argon2.Version {
return nil, nil, nil, errors.New("version argon2 incompatible")
}
p := &params{}
_, err = fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &p.memory, &p.iterations, &p.parallelism)
if err != nil {
return nil, nil, nil, err
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return nil, nil, nil, err
}
p.saltLength = uint32(len(salt))
hash, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return nil, nil, nil, err
}
p.keyLength = uint32(len(hash))
return p, salt, hash, nil
}
@@ -0,0 +1,75 @@
package password
import (
"context"
"fmt"
"trankilou.fr/lassistanoque/backend/internal/domain"
"trankilou.fr/lassistanoque/backend/internal/service/auth"
)
type PasswordAuthenticator struct {
tokenManager auth.TokenManager
userRepository domain.UserRepository
}
func NewPasswordAuthenticator(
tokenManager auth.TokenManager,
userRepository domain.UserRepository,
) *PasswordAuthenticator {
return &PasswordAuthenticator{
tokenManager,
userRepository,
}
}
func (s *PasswordAuthenticator) Authenticate(ctx context.Context, creds auth.Credentials) (*auth.Session, error) {
user, err := s.userRepository.FindUserByEmail(creds.Email)
if err != nil {
return nil, fmt.Errorf("Unauthorized user %s : %s", creds.Email, err)
}
ok, err := VerifyPassword(creds.Password, user.Password)
if !ok {
return nil, fmt.Errorf("Password don't match")
}
if err != nil {
return nil, fmt.Errorf("Error verifying password: %s", err)
}
accessToken, expiresAt, err := s.tokenManager.GenerateAccessToken(user)
if err != nil {
return nil, fmt.Errorf("Unable to generate access token: %s", err)
}
refreshToken, _, err := s.tokenManager.GenerateRefreshToken(user.ID)
if err != nil {
return nil, fmt.Errorf("Unable to generate refresh token: %s", err)
}
return &auth.Session{
User: user,
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresAt: expiresAt,
}, nil
}
func (s *PasswordAuthenticator) Register(ctx context.Context, registration auth.Registration) (*auth.Session, error) {
hashedPassord, err := HashPassword(registration.Password)
if err != nil {
return nil, err
}
user, err := s.userRepository.CreateUser(&domain.User{
Email: registration.Email,
Firstname: registration.Firstname,
Lastname: registration.Lastname,
Password: hashedPassord,
})
if err != nil {
return nil, err
}
_ = user
return nil, nil
}
@@ -0,0 +1,7 @@
package dberrors
import "errors"
var (
ErrNoRowUpdated = errors.New("No row was updated")
)
@@ -0,0 +1,26 @@
package database
import (
"fmt"
"trankilou.fr/lassistanoque/backend/internal/adapter/database/turso"
"trankilou.fr/lassistanoque/backend/internal/config"
"trankilou.fr/lassistanoque/backend/internal/domain"
)
type Database interface {
SettingsReporitory() domain.SettingsRepository
UserReporitory() domain.UserRepository
Migrate() error
Close()
}
func GetDatabase() (Database, error) {
cfg := config.GetConfig()
switch cfg.DatabaseType {
case "turso":
return turso.NewTursoDB(cfg)
default:
}
return nil, fmt.Errorf("Database %s not implemented", cfg.DatabaseType)
}
@@ -0,0 +1,69 @@
package turso
import (
"embed"
"fmt"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/sqlite3"
"github.com/golang-migrate/migrate/v4/source/iofs"
"github.com/jmoiron/sqlx"
_ "github.com/mattn/go-sqlite3"
"trankilou.fr/lassistanoque/backend/internal/config"
"trankilou.fr/lassistanoque/backend/internal/domain"
_ "turso.tech/database/tursogo"
)
//go:embed migrations/*
var FS embed.FS
type TursoDB struct {
DB *sqlx.DB
}
func NewTursoDB(cfg *config.Config) (*TursoDB, error) {
db, err := sqlx.Connect("turso", cfg.DatabaseURL)
if err != nil {
return nil, fmt.Errorf("error connecting to db %: %s", cfg.DatabaseURL, err)
}
return &TursoDB{
DB: db,
}, nil
}
func (db *TursoDB) UserReporitory() domain.UserRepository {
return &TursoUserRepository{
DB: db.DB,
}
}
func (db *TursoDB) SettingsReporitory() domain.SettingsRepository {
return &TursoSettingsRepository{
DB: db.DB,
}
}
func (db *TursoDB) Close() {
db.DB.Close()
}
func (db *TursoDB) Migrate() error {
sqldb := db.DB.DB
dbDriver, err := sqlite3.WithInstance(sqldb, &sqlite3.Config{})
if err != nil {
return err
}
sourceInstance, err := iofs.New(FS, "migrations")
if err != nil {
return err
}
m, err := migrate.NewWithInstance("iofs", sourceInstance, "sqlite3", dbDriver)
if err != nil {
return err
}
m.Up()
return nil
}
@@ -0,0 +1,10 @@
drop table providers;
drop table models;
drop table system;
drop table oidc;
drop table users;
drop table user_addresses;
drop table channels;
drop table tools;
drop table tasks;
drop table history;
@@ -0,0 +1,95 @@
create table providers (
id text not null primary key,
name text,
key text,
url text,
_version text
);
create table models (
id text not null primary key,
provider_id text,
name text,
modelname text,
configuration text,
_version text
);
create table settings (
chat_model_id text,
default_lang text,
register_enabled numeric,
password_enabled numeric,
_version text
);
create table oidc (
id string not null primary key,
label string,
domain string,
client_id string,
client_secret string,
wellknown_url string,
_version text
);
create table users (
id string not null primary key,
firstname string not null default '',
lastname string not null default '',
password string not null default '',
email string not null unique,
picture string not null default '',
enabled numeric default true,
administrator numeric default false,
_version text not null
);
create table user_addresses (
id string not null primary key,
user_id string,
type string,
address string,
_version text
);
create table channels (
id string not null primary key,
name string,
type string,
enabled numeric,
configuration string,
_version text
);
create table tools (
id string not null primary key,
name string,
type string,
enabled numeric,
configuration string,
_version text
);
create table tasks (
id string not null primary key,
owner_id string,
model_id string,
label string,
prompt string,
cron string,
status string,
next_datetime numeric,
_version text
);
create table history (
id string not null primary key,
task_id string,
start_datetimle numeric,
end_datetime numeric,
prompt string,
log string,
response string,
_version text
);
@@ -0,0 +1 @@
delete from sytem;
@@ -0,0 +1,2 @@
insert into settings (default_lang, register_enabled, password_enabled, _version)
values ('en', true, true, 'init');
@@ -0,0 +1,45 @@
package turso
import (
"github.com/jmoiron/sqlx"
"trankilou.fr/lassistanoque/backend/internal/adapter/database/dberrors"
"trankilou.fr/lassistanoque/backend/internal/domain"
"trankilou.fr/lassistanoque/backend/internal/utility"
)
type TursoSettingsRepository struct {
DB *sqlx.DB
}
func (sr *TursoSettingsRepository) GetSettings() (*domain.Settings, error) {
var settings domain.Settings
err := sr.DB.Get(&settings, "select * from settings limit 1")
return &settings, err
}
func (sr *TursoSettingsRepository) UpdateSettings(setting *domain.Settings) (*domain.Settings, error) {
newVersion := utility.GenID()
res, err := sr.DB.Exec(
`update settings
set chat_model_id=$1,
default_lang=$2,
register_enabled=$3,
password_enabled=$4,
_version=:$5
where _version=$6`,
setting.ChatModelID, setting.DefaultLang, setting.RegisterEnabled,
setting.PasswordEnabled, newVersion, setting.VersionId,
)
if err != nil {
return nil, err
}
if count, _ := res.RowsAffected(); count == 0 {
return nil, dberrors.ErrNoRowUpdated
}
setting.VersionId = newVersion
return setting, nil
}
@@ -0,0 +1,93 @@
package turso
import (
"github.com/jmoiron/sqlx"
"trankilou.fr/lassistanoque/backend/internal/adapter/database/dberrors"
"trankilou.fr/lassistanoque/backend/internal/domain"
"trankilou.fr/lassistanoque/backend/internal/utility"
)
type TursoUserRepository struct {
DB *sqlx.DB
}
func (ur *TursoUserRepository) FindUser(id string) (*domain.User, error) {
var user domain.User
err := ur.DB.Get(&user, "select * from users where id=$1", id)
return &user, err
}
func (ur *TursoUserRepository) FindUserByEmail(email string) (*domain.User, error) {
var user domain.User
err := ur.DB.Get(&user, "select * from users where email=$1", email)
return &user, err
}
func (ur *TursoUserRepository) CountUsers() (int, error) {
var count int
err := ur.DB.Get(&count, "select count(*) from users")
return count, err
}
func (ur *TursoUserRepository) ListUsers() ([]*domain.User, error) {
var users []*domain.User
err := ur.DB.Select(&users, "select * from users order by lastname, firstname")
return users, err
}
func (ur *TursoUserRepository) CreateUser(user *domain.User) (*domain.User, error) {
count, err := ur.CountUsers()
if err != nil {
return nil, err
}
if count == 0 {
user.Administrator = true
}
user.ID = utility.GenID()
user.VersionId = utility.GenID()
ur.DB.NamedExec(
`insert into users (id, email, firstname, lastname, enabled, password, administrator, _version)
values (:id, :email, :firstname, :lastname, :enabled, :password, :administrator, :_version)`,
user)
return nil, nil
}
func (ur *TursoUserRepository) UpdateUser(user *domain.User) (*domain.User, error) {
newVersion := utility.GenID()
res, err := ur.DB.Exec(
`update users
set email=$1,
firstname=$2,
lastname=$3,
enabled=$4,
administrator=$5
_version=:$6
where id=$7 and _version=$8`,
user.Email, user.Firstname, user.Lastname,
user.Enabled, user.Administrator,
newVersion, user.ID, user.VersionId,
)
if err != nil {
return nil, err
}
if count, _ := res.RowsAffected(); count == 0 {
return nil, dberrors.ErrNoRowUpdated
}
user.VersionId = newVersion
return user, nil
}
func (ur *TursoUserRepository) DeleteUser(id string) error {
res, err := ur.DB.Exec("delete from users where id=$1", id)
if count, _ := res.RowsAffected(); count == 0 {
return dberrors.ErrNoRowUpdated
}
return err
}
+129
View File
@@ -0,0 +1,129 @@
package security
import (
"errors"
"net/http"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/labstack/echo/v5"
"trankilou.fr/lassistanoque/backend/internal/config"
"trankilou.fr/lassistanoque/backend/internal/domain"
"trankilou.fr/lassistanoque/backend/internal/service/auth"
)
// Claims personnalisées embarquées dans le JWT.
type Claims struct {
UserID string `json:"uid"`
Email string `json:"email"`
Firstname string `json:"firstname"`
Lastname string `json:"lastname"`
Administrator bool `json:"administrator"`
jwt.RegisteredClaims
}
type JwtTokenManager struct {
secret []byte
accessTTL time.Duration
refreshTTL time.Duration
issuer string
}
func NewJwtTokenManager(accessTTL, refreshTTL time.Duration, issuer string) *JwtTokenManager {
cfg := config.GetConfig()
return &JwtTokenManager{
secret: []byte(cfg.JWTSecret),
accessTTL: accessTTL,
refreshTTL: refreshTTL,
issuer: issuer,
}
}
// GenerateAccessToken crée un JWT de courte durée (ex: 15 min) utilisé
// pour authentifier les requêtes API.
func (tm *JwtTokenManager) GenerateAccessToken(user *domain.User) (string, time.Time, error) {
expiresAt := time.Now().Add(tm.accessTTL)
claims := Claims{
UserID: user.ID,
Email: user.Email,
Firstname: user.Firstname,
Lastname: user.Lastname,
Administrator: user.Administrator,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: tm.issuer,
Subject: user.ID,
ExpiresAt: jwt.NewNumericDate(expiresAt),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString(tm.secret)
return signed, expiresAt, err
}
// GenerateRefreshToken crée un token de longue durée (ex: 7 jours) utilisé
// uniquement pour obtenir un nouvel access token, jamais pour appeler l'API directement.
func (tm *JwtTokenManager) GenerateRefreshToken(userID string) (string, time.Time, error) {
expiresAt := time.Now().Add(tm.refreshTTL)
claims := jwt.RegisteredClaims{
Issuer: tm.issuer,
Subject: userID,
ExpiresAt: jwt.NewNumericDate(expiresAt),
IssuedAt: jwt.NewNumericDate(time.Now()),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString(tm.secret)
return signed, expiresAt, err
}
// ParseAndValidate décode et vérifie la signature + l'expiration d'un JWT.
func (tm *JwtTokenManager) ParseAndValidate(tokenString string) (*domain.User, error) {
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("méthode de signature inattendue")
}
return tm.secret, nil
})
if err != nil {
return nil, err
}
if !token.Valid {
return nil, errors.New("token invalide")
}
return &domain.User{
ID: claims.UserID,
Email: claims.Email,
Firstname: claims.Firstname,
Lastname: claims.Lastname,
Administrator: claims.Administrator,
}, nil
}
func (tm *JwtTokenManager) TokenMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
header := c.Request().Header.Get("Authorization")
if header == "" {
return echo.NewHTTPError(http.StatusUnauthorized, "en-tête Authorization manquant")
}
parts := strings.SplitN(header, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
return echo.NewHTTPError(http.StatusUnauthorized, "format attendu: Bearer <token>")
}
claims, err := tm.ParseAndValidate(parts[1])
if err != nil {
return echo.NewHTTPError(http.StatusUnauthorized, "token invalide ou expiré")
}
// Rend les infos disponibles aux handlers suivants via c.Get(...)
c.Set(auth.ContextUserIDKey, claims.ID)
c.Set(auth.ContextEmailKey, claims.Email)
c.Set(auth.ContextNameKey, claims.Firstname+" "+claims.Lastname)
c.Set(auth.ContextAdminKey, claims.Administrator)
return next(c)
}
}