Add account management features: implement GetAccount endpoint, update Login method to return token, enhance repository and service layers, and add JWT middleware for authentication. Update .gitignore to include tmp directory.
Some checks failed
ci/woodpecker/push/lint Pipeline failed
ci/woodpecker/push/tests Pipeline failed
ci/woodpecker/push/build unknown status

This commit is contained in:
qpismont 2025-03-24 20:43:00 +00:00
parent c2a78d820e
commit a5e059a636
19 changed files with 260 additions and 6 deletions

51
.air.toml Normal file
View file

@ -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

4
.gitignore vendored
View file

@ -25,4 +25,6 @@ go.work.sum
.env
# dist directory
dist/
dist/
tmp/

View file

@ -0,0 +1,3 @@
meta {
name: accounts
}

18
bruno/accounts/login.bru Normal file
View file

@ -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"
}
}

9
bruno/bruno.json Normal file
View file

@ -0,0 +1,9 @@
{
"version": "1",
"name": "trepa",
"type": "collection",
"ignore": [
"node_modules",
".git"
]
}

View file

@ -0,0 +1,3 @@
vars {
base_url: 127.0.0.1:3000
}

View file

@ -0,0 +1,3 @@
vars {
base_url: {{process.env.BASE_URL}}
}

View file

@ -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)
}

View file

@ -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"`
}

View file

@ -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))
}

View file

@ -14,6 +14,11 @@ type AccountLogin struct {
Password string
}
type AccountWithToken struct {
Account *Account
Token string
}
type AccountCreate struct {
Username string
Password string

View file

@ -3,4 +3,5 @@ package domain
type AccountRepository interface {
Insert(account Account) (int, error)
FetchOneByUsername(username string) (*Account, error)
FetchOneById(id int) (*Account, error)
}

View file

@ -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)
}

View file

@ -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

View file

@ -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)
}

View file

@ -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"
)

View file

@ -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
}

View file

@ -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

View file

@ -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)
}
}