From 0ae9cd7133edb766d029da8714b02caff7a33a48 Mon Sep 17 00:00:00 2001 From: juancwu <46619361+juancwu@users.noreply.github.com> Date: Fri, 12 Dec 2025 11:54:20 -0500 Subject: [PATCH] setup user/auth repository and service --- go.mod | 1 + go.sum | 26 +++++++++ internal/app/app.go | 19 +++++-- internal/exception/exception.go | 15 +++++ internal/repository/user.go | 98 +++++++++++++++++++++++++++++++++ internal/service/auth.go | 69 +++++++++++++++++++++++ internal/service/user.go | 25 +++++++++ 7 files changed, 249 insertions(+), 4 deletions(-) create mode 100644 internal/exception/exception.go create mode 100644 internal/repository/user.go create mode 100644 internal/service/auth.go create mode 100644 internal/service/user.go diff --git a/go.mod b/go.mod index 2fa0381..f7b4dcb 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.1 require ( github.com/Oudwins/tailwind-merge-go v0.2.1 github.com/a-h/templ v0.3.960 + github.com/alexedwards/argon2id v1.0.0 github.com/jackc/pgx/v5 v5.7.6 github.com/jmoiron/sqlx v1.4.0 github.com/joho/godotenv v1.5.1 diff --git a/go.sum b/go.sum index bbe3817..d865300 100644 --- a/go.sum +++ b/go.sum @@ -25,6 +25,8 @@ github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ6 github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ= github.com/a-h/templ v0.3.960 h1:trshEpGa8clF5cdI39iY4ZrZG8Z/QixyzEyUnA7feTM= github.com/a-h/templ v0.3.960/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo= +github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w= +github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= @@ -226,6 +228,7 @@ github.com/ydb-platform/ydb-go-sdk/v3 v3.108.1/go.mod h1:l5sSv153E18VvYcsmr51hok github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs= github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g= @@ -241,7 +244,9 @@ go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN8 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -252,6 +257,8 @@ golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvx golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -264,7 +271,11 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -276,6 +287,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -288,14 +301,25 @@ golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -306,6 +330,8 @@ golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBn golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/app/app.go b/internal/app/app.go index 1e17d36..a3cc268 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -5,12 +5,16 @@ import ( "git.juancwu.dev/juancwu/budgething/internal/config" "git.juancwu.dev/juancwu/budgething/internal/db" + "git.juancwu.dev/juancwu/budgething/internal/repository" + "git.juancwu.dev/juancwu/budgething/internal/service" "github.com/jmoiron/sqlx" ) type App struct { - Cfg *config.Config - DB *sqlx.DB + Cfg *config.Config + DB *sqlx.DB + UserService *service.UserService + AuthService *service.AuthService } func New(cfg *config.Config) (*App, error) { @@ -24,9 +28,16 @@ func New(cfg *config.Config) (*App, error) { return nil, fmt.Errorf("failed to run migrations: %w", err) } + userRepository := repository.NewUserRepository(database) + + userService := service.NewUserService(userRepository) + authService := service.NewAuthService(userRepository) + return &App{ - Cfg: cfg, - DB: database, + Cfg: cfg, + DB: database, + UserService: userService, + AuthService: authService, }, nil } diff --git a/internal/exception/exception.go b/internal/exception/exception.go new file mode 100644 index 0000000..a106b56 --- /dev/null +++ b/internal/exception/exception.go @@ -0,0 +1,15 @@ +package exception + +import "fmt" + +type Exception struct { + Operation string +} + +func New(operation string) *Exception { + return &Exception{Operation: operation} +} + +func (e *Exception) WithError(err error) error { + return fmt.Errorf("%s: %w", e.Operation, err) +} diff --git a/internal/repository/user.go b/internal/repository/user.go new file mode 100644 index 0000000..4ec6cbb --- /dev/null +++ b/internal/repository/user.go @@ -0,0 +1,98 @@ +package repository + +import ( + "database/sql" + "errors" + "strings" + + "git.juancwu.dev/juancwu/budgething/internal/model" + "github.com/jmoiron/sqlx" +) + +var ( + ErrUserNotFound = errors.New("user not found") + ErrDuplicateEmail = errors.New("email already exists") +) + +type UserRepository interface { + Create(user *model.User) error + ByID(id string) (*model.User, error) + ByEmail(email string) (*model.User, error) + Update(user *model.User) error + Delete(id string) error +} + +type userRepository struct { + db *sqlx.DB +} + +func NewUserRepository(db *sqlx.DB) UserRepository { + return &userRepository{db: db} +} + +func (r *userRepository) Create(user *model.User) error { + query := `INSERT INTO users (id, email, password_hash, email_verified_at, created_at) VALUES ($1, $2, $3, $4, $5);` + + _, err := r.db.Exec(query, user.ID, user.Email, user.PasswordHash, user.EmailVerifiedAt, user.CreatedAt) + if err != nil { + errStr := err.Error() + if strings.Contains(errStr, "UNIQUE constraint failed") || strings.Contains(errStr, "duplicate key value") { + return ErrDuplicateEmail + } + return err + } + + return nil +} + +func (r *userRepository) ByID(id string) (*model.User, error) { + user := &model.User{} + query := `SELECT * FROM users WHERE id = $1;` + + err := r.db.Get(user, query, id) + if err == sql.ErrNoRows { + return nil, ErrUserNotFound + } + + return user, err +} + +func (r *userRepository) ByEmail(email string) (*model.User, error) { + user := &model.User{} + query := `SELECT * FROM users WHERE email = $1;` + + err := r.db.Get(user, query, email) + if err == sql.ErrNoRows { + return nil, ErrUserNotFound + } + + return user, err +} + +func (r *userRepository) Update(user *model.User) error { + query := `UPDATE users SET email = $1, password_hash = $2, pending_email = $3, email_verified_at = $4 WHERE id = $5;` + + _, err := r.db.Exec(query, user.Email, user.PasswordHash, user.PendingEmail, user.EmailVerifiedAt, user.ID) + + return err +} + +func (r *userRepository) Delete(id string) error { + query := `DELETE FROM users WHERE id = $1;` + + result, err := r.db.Exec(query, id) + if err != nil { + return err + } + + rows, err := result.RowsAffected() + if err != nil { + return err + } + + if rows == 0 { + return ErrUserNotFound + } + + return nil +} diff --git a/internal/service/auth.go b/internal/service/auth.go new file mode 100644 index 0000000..d4d5490 --- /dev/null +++ b/internal/service/auth.go @@ -0,0 +1,69 @@ +package service + +import ( + "errors" + "strings" + + "git.juancwu.dev/juancwu/budgething/internal/exception" + "git.juancwu.dev/juancwu/budgething/internal/model" + "git.juancwu.dev/juancwu/budgething/internal/repository" + "github.com/alexedwards/argon2id" +) + +var ( + ErrInvalidCredentials = errors.New("invalid email or password") + ErrNoPassword = errors.New("account uses passwordless login. Use magic link") + ErrPasswordsDoNotMatch = errors.New("passwords do not match") +) + +type AuthService struct { + userRepository repository.UserRepository +} + +func NewAuthService(userRepository repository.UserRepository) *AuthService { + return &AuthService{ + userRepository: userRepository, + } +} + +func (s *AuthService) LoginWithPassword(email, password string) (*model.User, error) { + e := exception.New("AuthService.LoginWithPassword") + + email = strings.TrimSpace(strings.ToLower(email)) + + user, err := s.userRepository.ByEmail(email) + if err != nil { + if errors.Is(err, repository.ErrUserNotFound) { + return nil, e.WithError(ErrInvalidCredentials) + } + return nil, e.WithError(err) + } + + if !user.HasPassword() { + return nil, e.WithError(ErrNoPassword) + } + + return user, nil +} + +func (s *AuthService) HashPassword(password string) (string, error) { + e := exception.New("AuthService.HashPassword") + + hashed, err := argon2id.CreateHash(password, argon2id.DefaultParams) + if err != nil { + return "", e.WithError(err) + } + return hashed, nil +} + +func (s *AuthService) ComparePassword(password, hash string) error { + e := exception.New("AuthService.ComparePassword") + match, err := argon2id.ComparePasswordAndHash(password, hash) + if err != nil { + return e.WithError(err) + } + if !match { + return e.WithError(ErrPasswordsDoNotMatch) + } + return nil +} diff --git a/internal/service/user.go b/internal/service/user.go new file mode 100644 index 0000000..6186bc6 --- /dev/null +++ b/internal/service/user.go @@ -0,0 +1,25 @@ +package service + +import ( + "git.juancwu.dev/juancwu/budgething/internal/model" + "git.juancwu.dev/juancwu/budgething/internal/repository" +) + +type UserService struct { + userRepository repository.UserRepository +} + +func NewUserService(userRepository repository.UserRepository) *UserService { + return &UserService{ + userRepository: userRepository, + } +} + +func (s *UserService) ByID(id string) (*model.User, error) { + user, err := s.userRepository.ByID(id) + if err != nil { + return nil, err + } + + return user, nil +}