From 3e28db75524d211f4bffd4a0b1895fcee2eb2128 Mon Sep 17 00:00:00 2001 From: phoenix Date: Sat, 13 Sep 2025 17:47:15 +0000 Subject: [PATCH] Login (#6) Reviewed-on: https://git.kundeng.us/phoenix/textsender-auth/pulls/6 Co-authored-by: phoenix Co-committed-by: phoenix --- .env.sample | 1 + .gitea/workflows/workflow.yaml | 16 ++++++ cmd/api/main.go | 2 + go.mod | 1 + go.sum | 2 + internal/config/config.go | 4 ++ internal/handler/endpoint/endpoint.go | 1 + internal/handler/login.go | 83 +++++++++++++++++++++++++++ internal/handler/login_test.go | 50 ++++++++++++++++ internal/handler/register.go | 31 +++------- internal/handler/register_test.go | 7 +-- internal/handler/utility.go | 21 +++++++ internal/handler/utility_test.go | 9 +++ internal/model/token.go | 15 +++++ internal/utility/hashing.go | 1 - internal/utility/token.go | 40 +++++++++++++ 16 files changed, 256 insertions(+), 28 deletions(-) create mode 100644 internal/handler/login.go create mode 100644 internal/handler/login_test.go create mode 100644 internal/handler/utility.go create mode 100644 internal/handler/utility_test.go create mode 100644 internal/model/token.go create mode 100644 internal/utility/token.go diff --git a/.env.sample b/.env.sample index 97d9947..e31f4d6 100644 --- a/.env.sample +++ b/.env.sample @@ -1,3 +1,4 @@ +SECRET_KEY=XY0aIEg8qe0svz4IXUSIvxPdbDHIwoda DB_NAME=textsender_auth_db DB_USER=textsender_auth DB_PASSWORD=yEDjWZCH2vdctjn! diff --git a/.gitea/workflows/workflow.yaml b/.gitea/workflows/workflow.yaml index 8ef3f18..e215546 100644 --- a/.gitea/workflows/workflow.yaml +++ b/.gitea/workflows/workflow.yaml @@ -51,6 +51,22 @@ jobs: with: go-version: '1.25.1' + - name: Install PostgreSQL client + run: sudo apt update && sudo apt-get install -y postgresql-client + + - name: Wait for PostgreSQL to be ready + run: | + for i in {1..30}; do + if pg_isready -h postgres -p 5432; then + echo "PostgreSQL is ready" + exit 0 + fi + echo "Waiting for PostgreSQL... Attempt $i" + sleep 2 + done + echo "PostgreSQL did not start in time" + exit 1 + - name: Run tests env: DB_NAME: ${{ secrets.DB_TEST_NAME }} diff --git a/cmd/api/main.go b/cmd/api/main.go index 33139e0..0d8da90 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -46,6 +46,7 @@ func main() { // Services userStore := model.NewUserStore(db.Pool) userHandler := handler.NewUserHandler(userStore) + loginHandler := handler.NewLoginHandler(userStore) router := chi.NewRouter() @@ -55,6 +56,7 @@ func main() { router.Use(mdleware.JSONContentType) router.Post(endpoint.Register, userHandler.Register) + router.Post(endpoint.Login, loginHandler.Login) // Start server server := &http.Server{ diff --git a/go.mod b/go.mod index 4ceaf7a..2993bef 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.1 require ( github.com/go-chi/chi/v5 v5.2.3 + github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/uuid v1.6.0 github.com/gorilla/mux v1.8.1 github.com/jackc/pgx/v5 v5.7.5 diff --git a/go.sum b/go.sum index 8669674..f0b5e2d 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= diff --git a/internal/config/config.go b/internal/config/config.go index b512ff3..7e8264f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -51,6 +51,10 @@ func Load() *Config { } } +func GetSecretKey() string { + return os.Getenv("SECRET_KEY") +} + func UnpackDBConnString() (connInfo ConnectionInfo) { username := os.Getenv("DB_USER") password := os.Getenv("DB_PASSWORD") diff --git a/internal/handler/endpoint/endpoint.go b/internal/handler/endpoint/endpoint.go index 2ed3196..88b9582 100644 --- a/internal/handler/endpoint/endpoint.go +++ b/internal/handler/endpoint/endpoint.go @@ -2,3 +2,4 @@ package endpoint // Endpoint for registering a user const Register = "/api/v1/register" +const Login = "/api/v1/login" diff --git a/internal/handler/login.go b/internal/handler/login.go new file mode 100644 index 0000000..e3e9b37 --- /dev/null +++ b/internal/handler/login.go @@ -0,0 +1,83 @@ +package handler + +import ( + "fmt" + "net/http" + + "git.kundeng.us/phoenix/textsender-auth/internal/config" + "git.kundeng.us/phoenix/textsender-auth/internal/model" + "git.kundeng.us/phoenix/textsender-auth/internal/utility" +) + +type LoginAccount struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type LoginResponse struct { + Message string `json:"message"` + Data []model.Login `json:"data"` +} + +type LoginHandler struct { + UserStore model.UserStore +} + +func NewLoginHandler(userStore model.UserStore) *LoginHandler { + return &LoginHandler{UserStore: userStore} +} + +func (l *LoginHandler) Login(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req LoginAccount + if err := ExtractFromRequest(r, &req); err != nil { + http.Error(w, "Invalid JSON: "+err.Error(), http.StatusBadRequest) + } + defer r.Body.Close() + + var statusCode int + var resp LoginResponse + + ctx := r.Context() + + if exists, err := l.UserStore.UserExists(ctx, req.Username); err != nil { + fmt.Printf("Error: %v", err) + statusCode = http.StatusInternalServerError + resp.Message = err.Error() + } else { + if !exists { + statusCode = http.StatusBadRequest + resp.Message = "Failure in user check" + } else { + if user, err := l.UserStore.GetUserByUsername(ctx, req.Username); err != nil { + statusCode = http.StatusInternalServerError + resp.Message = err.Error() + } else { + hashing := utility.HashMash{Password: req.Password} + if hashing.CheckPasswordHash(req.Password, user.Password) { + var tokGen utility.TokenGenerator + secretKey := config.GetSecretKey() + tokGen.SetSecretKey(secretKey) + if token, err := tokGen.GenerateToken(*user); err != nil { + fmt.Println(err.Error()) + statusCode = http.StatusInternalServerError + resp.Message = "Error generating token" + } else { + statusCode = http.StatusOK + resp.Data = append(resp.Data, *token) + resp.Message = "Successful" + } + } else { + statusCode = http.StatusNotFound + resp.Message = "User not found" + } + } + } + } + + RespondWithJson(w, statusCode, &resp) +} diff --git a/internal/handler/login_test.go b/internal/handler/login_test.go new file mode 100644 index 0000000..491763b --- /dev/null +++ b/internal/handler/login_test.go @@ -0,0 +1,50 @@ +package handler + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "git.kundeng.us/phoenix/textsender-auth/internal/handler/endpoint" + "git.kundeng.us/phoenix/textsender-auth/internal/utility" +) + +func TestLogin(t *testing.T) { + mockstore := NewMockUserStore() + handler := NewLoginHandler(mockstore) + + testUser := GetTestUser() + unhashedPassword := testUser.Password + hashing := utility.HashMash{Password: testUser.Password} + hashedPassword, err := hashing.HashPassword() + assert.NoError(t, err) + + testUser.Password = hashedPassword + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + mockstore.CreateUser(ctx, &testUser) + + loginUser := LoginAccount{Username: testUser.Username, Password: unhashedPassword} + jsonValue, _ := json.Marshal(loginUser) + + req, _ := http.NewRequest("POST", endpoint.Login, strings.NewReader(string(jsonValue))) + rr := httptest.NewRecorder() + + handler.Login(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + + var response LoginResponse + err = json.Unmarshal(rr.Body.Bytes(), &response) + assert.NoError(t, err) + + assert.NotEmpty(t, response.Data, "An access token should have been returned") +} diff --git a/internal/handler/register.go b/internal/handler/register.go index 31ed41c..5c04797 100644 --- a/internal/handler/register.go +++ b/internal/handler/register.go @@ -1,11 +1,9 @@ package handler -import "net/http" -import "encoding/json" -import "fmt" - import ( + "fmt" "github.com/google/uuid" + "net/http" "git.kundeng.us/phoenix/textsender-auth/internal/model" "git.kundeng.us/phoenix/textsender-auth/internal/utility" @@ -42,7 +40,8 @@ func (h *UserHandler) Register(w http.ResponseWriter, r *http.Request) { return } - user, err := extractUserFromReq(r) + var req RegisterUser + err := ExtractFromRequest(r, &req) if err != nil { http.Error(w, "Invalid JSON: "+err.Error(), http.StatusBadRequest) return @@ -50,6 +49,8 @@ func (h *UserHandler) Register(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() + user := model.User{Username: req.Username, Password: req.Password, PhoneNumber: req.PhoneNumber} + var statusCode int resp := RegisterResponse{} @@ -69,7 +70,7 @@ func (h *UserHandler) Register(w http.ResponseWriter, r *http.Request) { statusCode = http.StatusBadRequest resp.Message = "Failure in creating User" } else { - hashing := utility.HashMash{user.Password} + hashing := utility.HashMash{Password: user.Password} hashedPassword, err := hashing.HashPassword() if err != nil { statusCode = http.StatusInternalServerError @@ -88,21 +89,5 @@ func (h *UserHandler) Register(w http.ResponseWriter, r *http.Request) { } } - respondWithJson(w, statusCode, &resp) -} - -func extractUserFromReq(r *http.Request) (user model.User, myError error) { - var usr RegisterUser - err := json.NewDecoder(r.Body).Decode(&usr) - if err != nil { - return user, err - } - - return model.User{PhoneNumber: usr.PhoneNumber, Username: usr.Username, Password: usr.Password}, nil -} - -func respondWithJson(w http.ResponseWriter, statusCode int, data interface{}) { - w.Header().Set("Content-type", "application/json") - w.WriteHeader(statusCode) - json.NewEncoder(w).Encode(data) + RespondWithJson(w, statusCode, &resp) } diff --git a/internal/handler/register_test.go b/internal/handler/register_test.go index 6906630..382dcf6 100644 --- a/internal/handler/register_test.go +++ b/internal/handler/register_test.go @@ -8,18 +8,17 @@ import ( "strings" "testing" + "github.com/stretchr/testify/assert" + "git.kundeng.us/phoenix/textsender-auth/internal/db" "git.kundeng.us/phoenix/textsender-auth/internal/handler/endpoint" - "git.kundeng.us/phoenix/textsender-auth/internal/model" - - "github.com/stretchr/testify/assert" ) func TestCreateUserWithMock(t *testing.T) { mockstore := NewMockUserStore() handler := NewUserHandler(mockstore) - testUser := model.User{Username: "ghost", PhoneNumber: "+1234567890", Password: "dfgdffddfd"} + testUser := GetTestUser() jsonValue, _ := json.Marshal(testUser) req, _ := http.NewRequest("POST", endpoint.Register, strings.NewReader(string(jsonValue))) diff --git a/internal/handler/utility.go b/internal/handler/utility.go new file mode 100644 index 0000000..155ef3b --- /dev/null +++ b/internal/handler/utility.go @@ -0,0 +1,21 @@ +package handler + +import ( + "encoding/json" + "net/http" +) + +func ExtractFromRequest(r *http.Request, reqItem interface{}) error { + err := json.NewDecoder(r.Body).Decode(&reqItem) + if err != nil { + return err + } else { + return nil + } +} + +func RespondWithJson(w http.ResponseWriter, statusCode int, data interface{}) { + w.Header().Set("Content-type", "application/json") + w.WriteHeader(statusCode) + json.NewEncoder(w).Encode(data) +} diff --git a/internal/handler/utility_test.go b/internal/handler/utility_test.go new file mode 100644 index 0000000..f1ff33b --- /dev/null +++ b/internal/handler/utility_test.go @@ -0,0 +1,9 @@ +package handler + +import ( + "git.kundeng.us/phoenix/textsender-auth/internal/model" +) + +func GetTestUser() model.User { + return model.User{Username: "ghost", PhoneNumber: "+1234567890", Password: "dfgdffddfd"} +} diff --git a/internal/model/token.go b/internal/model/token.go new file mode 100644 index 0000000..2e99690 --- /dev/null +++ b/internal/model/token.go @@ -0,0 +1,15 @@ +package model + +import ( + "github.com/golang-jwt/jwt/v5" +) + +type Claims struct { + UserId string `json:"user_id"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +type Login struct { + AccessToken string `json:"access_token"` +} diff --git a/internal/utility/hashing.go b/internal/utility/hashing.go index 8048137..2bc324e 100644 --- a/internal/utility/hashing.go +++ b/internal/utility/hashing.go @@ -1,6 +1,5 @@ package utility - import ( "golang.org/x/crypto/bcrypt" ) diff --git a/internal/utility/token.go b/internal/utility/token.go new file mode 100644 index 0000000..85a18ec --- /dev/null +++ b/internal/utility/token.go @@ -0,0 +1,40 @@ +package utility + +import ( + "time" + + "github.com/golang-jwt/jwt/v5" + + "git.kundeng.us/phoenix/textsender-auth/internal/model" +) + +type TokenGenerator struct { + SecretKey []byte +} + +func (t *TokenGenerator) SetSecretKey(secretKey string) { + t.SecretKey = []byte(secretKey) +} + +func (t *TokenGenerator) GenerateToken(user model.User) (*model.Login, error) { + claims := t.generateClaims(user, "regular") + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + if tokenString, err := token.SignedString(t.SecretKey); err != nil { + return nil, err + } else { + return &model.Login{AccessToken: tokenString}, nil + } +} + +func (t *TokenGenerator) generateClaims(user model.User, role string) model.Claims { + return model.Claims{ + UserId: user.Id.String(), + Role: role, + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: "textsender-auth", + ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } +}