diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..18751e5 --- /dev/null +++ b/.air.toml @@ -0,0 +1,51 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./main" + cmd = "go build -o ./main ./cmd/api/main.go" + delay = 1000 + exclude_dir = ["tmp", "vendor", "cmd/scripts"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + time = false + +[misc] + clean_on_exit = false + +[proxy] + app_port = 0 + enabled = false + proxy_port = 0 + +[screen] + clear_on_rebuild = true + keep_scroll = true \ No newline at end of file diff --git a/.gitignore b/.gitignore index c759515..e99ee53 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,6 @@ go.work.sum .env # dist directory -dist/ \ No newline at end of file +dist/ + +tmp/ \ No newline at end of file diff --git a/bruno/accounts/folder.bru b/bruno/accounts/folder.bru new file mode 100644 index 0000000..c054647 --- /dev/null +++ b/bruno/accounts/folder.bru @@ -0,0 +1,3 @@ +meta { + name: accounts +} diff --git a/bruno/accounts/login.bru b/bruno/accounts/login.bru new file mode 100644 index 0000000..e123bf1 --- /dev/null +++ b/bruno/accounts/login.bru @@ -0,0 +1,18 @@ +meta { + name: login + type: http + seq: 1 +} + +post { + url: {{base_url}}/accounts/login + body: json + auth: inherit +} + +body:json { + { + "username": "lol", + "password": "lol" + } +} diff --git a/bruno/bruno.json b/bruno/bruno.json new file mode 100644 index 0000000..233a852 --- /dev/null +++ b/bruno/bruno.json @@ -0,0 +1,9 @@ +{ + "version": "1", + "name": "trepa", + "type": "collection", + "ignore": [ + "node_modules", + ".git" + ] +} \ No newline at end of file diff --git a/bruno/environments/dev.bru b/bruno/environments/dev.bru new file mode 100644 index 0000000..82175bb --- /dev/null +++ b/bruno/environments/dev.bru @@ -0,0 +1,3 @@ +vars { + base_url: 127.0.0.1:3000 +} diff --git a/bruno/environments/test.bru b/bruno/environments/test.bru new file mode 100644 index 0000000..e8e40ba --- /dev/null +++ b/bruno/environments/test.bru @@ -0,0 +1,3 @@ +vars { + base_url: {{process.env.BASE_URL}} +} diff --git a/internal/accounts/api/controller.go b/internal/accounts/api/controller.go index 1a246f1..b2682d0 100644 --- a/internal/accounts/api/controller.go +++ b/internal/accounts/api/controller.go @@ -16,9 +16,37 @@ type Controller struct { } func NewController(service domain.AccountService) Controller { + validate = validator.New() + return Controller{service: service} } +func (c Controller) GetAccount(w *core.Response, r *http.Request) { + claims := r.Context().Value("claims").(core.JWTClaims) + + account, httpErr := c.service.GetAccount(claims.AccountId) + if httpErr != nil { + w.WriteError(httpErr) + return + } + + response := GetAccountResponse{ + Id: account.Id, + Username: account.Username, + RoleId: account.RoleId, + CreatedAt: account.CreatedAt, + UpdatedAt: account.UpdatedAt, + } + + responseJson, err := json.Marshal(response) + if err != nil { + w.WriteError(core.NewInternalServerError(err)) + return + } + + w.Json(responseJson) +} + func (c Controller) Login(w *core.Response, r *http.Request) { var request LoginAccountRequest if err := json.NewDecoder(r.Body).Decode(&request); err != nil { @@ -31,4 +59,32 @@ func (c Controller) Login(w *core.Response, r *http.Request) { return } + result, httpErr := c.service.Login(domain.AccountLogin{ + Username: request.Username, + Password: request.Password, + }) + + if httpErr != nil { + w.WriteError(httpErr) + return + } + + response := LoginAccountResponse{ + Account: Account{ + Id: result.Account.Id, + Username: result.Account.Username, + RoleId: result.Account.RoleId, + CreatedAt: result.Account.CreatedAt, + UpdatedAt: result.Account.UpdatedAt, + }, + Token: result.Token, + } + + responseJson, err := json.Marshal(response) + if err != nil { + w.WriteError(core.NewInternalServerError(err)) + return + } + + w.Json(responseJson) } diff --git a/internal/accounts/api/dto.go b/internal/accounts/api/dto.go index aadd037..71d5ae4 100644 --- a/internal/accounts/api/dto.go +++ b/internal/accounts/api/dto.go @@ -4,3 +4,24 @@ type LoginAccountRequest struct { Username string `json:"username" validate:"required"` Password string `json:"password" validate:"required"` } + +type LoginAccountResponse struct { + Account Account `json:"account"` + Token string `json:"token"` +} + +type Account struct { + Id int `json:"id"` + Username string `json:"username"` + RoleId int `json:"role_id"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type GetAccountResponse struct { + Id int `json:"id"` + Username string `json:"username"` + RoleId int `json:"role_id"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} diff --git a/internal/accounts/api/routes.go b/internal/accounts/api/routes.go index 3f088db..6f2e76f 100644 --- a/internal/accounts/api/routes.go +++ b/internal/accounts/api/routes.go @@ -13,4 +13,5 @@ func BindRoutes(srv *core.ServerMux, db *sqlx.DB) { controller := NewController(service) srv.HandleFunc("POST /accounts/login", controller.Login) + srv.HandleFunc("GET /accounts/me", core.JwtMiddleware(controller.GetAccount)) } diff --git a/internal/accounts/domain/account.go b/internal/accounts/domain/account.go index d4209c7..e1219dc 100644 --- a/internal/accounts/domain/account.go +++ b/internal/accounts/domain/account.go @@ -14,6 +14,11 @@ type AccountLogin struct { Password string } +type AccountWithToken struct { + Account *Account + Token string +} + type AccountCreate struct { Username string Password string diff --git a/internal/accounts/domain/repository.go b/internal/accounts/domain/repository.go index 72210eb..fc3e6cc 100644 --- a/internal/accounts/domain/repository.go +++ b/internal/accounts/domain/repository.go @@ -3,4 +3,5 @@ package domain type AccountRepository interface { Insert(account Account) (int, error) FetchOneByUsername(username string) (*Account, error) + FetchOneById(id int) (*Account, error) } diff --git a/internal/accounts/domain/service.go b/internal/accounts/domain/service.go index 7cc23a9..b2171d4 100644 --- a/internal/accounts/domain/service.go +++ b/internal/accounts/domain/service.go @@ -3,5 +3,6 @@ package domain import "gitea.qpismont.fr/qpismont/trepa/internal/core" type AccountService interface { - Login(login AccountLogin) (*Account, *core.HTTPError) + Login(login AccountLogin) (*AccountWithToken, *core.HTTPError) + GetAccount(id int) (*Account, *core.HTTPError) } diff --git a/internal/accounts/repository/account.go b/internal/accounts/repository/account.go index 17caafb..b959d72 100644 --- a/internal/accounts/repository/account.go +++ b/internal/accounts/repository/account.go @@ -33,6 +33,21 @@ func (r *Repository) Insert(account domain.Account) (int, error) { return id, nil } +func (r *Repository) FetchOneById(id int) (*domain.Account, error) { + var account domain.Account + + err := r.db.Get(&account, SqlFetchOneById, id) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } else { + return nil, err + } + } + + return &account, nil +} + func (r *Repository) FetchOneByUsername(username string) (*domain.Account, error) { var account domain.Account diff --git a/internal/accounts/repository/account_test.go b/internal/accounts/repository/account_test.go index e1aaf7b..85cd846 100644 --- a/internal/accounts/repository/account_test.go +++ b/internal/accounts/repository/account_test.go @@ -47,3 +47,23 @@ func TestRepository_FetchOneByUsername(t *testing.T) { assert.Equal(t, account.Password, "LOLPASSWORD") assert.Equal(t, account.RoleId, 1) } + +func TestRepository_FetchOneById(t *testing.T) { + db := test.SetupTestDB(t, "../../..") + defer db.Close() + + repo := NewRepository(db) + + account, err := repo.FetchOneById(1) + if err != nil { + t.Fatalf("Failed to fetch account: %v", err) + } + + if account == nil { + t.Fatalf("Account not found") + } + + assert.Equal(t, account.Username, "admin") + assert.Equal(t, account.Password, "LOLPASSWORD") + assert.Equal(t, account.RoleId, 1) +} diff --git a/internal/accounts/repository/const.go b/internal/accounts/repository/const.go index 03b80c2..a09c820 100644 --- a/internal/accounts/repository/const.go +++ b/internal/accounts/repository/const.go @@ -3,4 +3,5 @@ package repository const ( SqlInsert = "INSERT INTO accounts (username, password, role_id) VALUES ($1, $2, $3) RETURNING id" SqlFetchOneByUsername = "SELECT * FROM accounts WHERE username = $1" + SqlFetchOneById = "SELECT * FROM accounts WHERE id = $1" ) diff --git a/internal/accounts/service/account.go b/internal/accounts/service/account.go index b6a13fa..910a3f1 100644 --- a/internal/accounts/service/account.go +++ b/internal/accounts/service/account.go @@ -13,10 +13,23 @@ func NewService(repository domain.AccountRepository) domain.AccountService { return &Service{repository: repository} } -func (s *Service) Login(login domain.AccountLogin) (*domain.Account, *core.HTTPError) { +func (s *Service) GetAccount(id int) (*domain.Account, *core.HTTPError) { + account, err := s.repository.FetchOneById(id) + if err != nil { + return nil, core.NewInternalServerError(err) + } + + if account == nil { + return nil, domain.ErrAccountNotFound + } + + return account, nil +} + +func (s *Service) Login(login domain.AccountLogin) (*domain.AccountWithToken, *core.HTTPError) { account, err := s.repository.FetchOneByUsername(login.Username) if err != nil { - return nil, domain.ErrAccountNotFound + return nil, core.NewInternalServerError(err) } ok, err := core.ComparePassword(login.Password, account.Password) @@ -28,5 +41,18 @@ func (s *Service) Login(login domain.AccountLogin) (*domain.Account, *core.HTTPE return nil, domain.ErrBadPassword } - return account, nil + claims := core.JWTClaims{ + AccountId: account.Id, + RoleId: account.RoleId, + } + + token, err := core.SignJWT(claims) + if err != nil { + return nil, core.NewInternalServerError(err) + } + + return &domain.AccountWithToken{ + Account: account, + Token: token, + }, nil } diff --git a/internal/core/jwt.go b/internal/core/jwt.go index 4515334..b1d7b5d 100644 --- a/internal/core/jwt.go +++ b/internal/core/jwt.go @@ -22,7 +22,7 @@ func SignJWT(claims JWTClaims) (string, error) { return token.SignedString([]byte(jwtSecret)) } -func VerifyJWT(token string) (JWTClaims, error) { +func VerifyJWT(token string) (JWTClaims, *HTTPError) { parsedClaims := JWTClaims{} claims, err := jwt.ParseWithClaims(token, &parsedClaims, func(token *jwt.Token) (any, error) { return []byte(jwtSecret), nil diff --git a/internal/core/middleware.go b/internal/core/middleware.go index c3a3db6..5092907 100644 --- a/internal/core/middleware.go +++ b/internal/core/middleware.go @@ -44,3 +44,21 @@ func ErrorMiddleware(next func(w *Response, r *http.Request)) func(w *Response, next(w, r) } } + +func JwtMiddleware(next func(w *Response, r *http.Request)) func(w *Response, r *http.Request) { + return func(w *Response, r *http.Request) { + token := r.Header.Get("Authorization") + if token == "" { + w.WriteError(ErrInvalidToken) + return + } + + _, err := VerifyJWT(token) + if err != nil { + w.WriteError(err) + return + } + + next(w, r) + } +}