From 0a00282ba75fd21e1b30a71777d2cd27ed91f625 Mon Sep 17 00:00:00 2001 From: phoenix Date: Fri, 21 Nov 2025 18:42:58 +0000 Subject: [PATCH] tsk-19: Service login (#23) Closes #19 Reviewed-on: https://git.kundeng.us/phoenix/textsender-auth/pulls/23 Co-authored-by: phoenix Co-committed-by: phoenix --- cmd/api/main.go | 1 + cmd/api/main_test.go | 1 + internal/handler/endpoint/endpoint.go | 1 + internal/handler/service.go | 91 ++++++++++++++++++++++++--- internal/handler/service_test.go | 37 +++++++++++ internal/store/mock/service_store.go | 24 +++++-- internal/store/service.go | 16 +++++ internal/utility/hashing.go | 4 +- internal/utility/token.go | 59 +++++++++++++---- 9 files changed, 205 insertions(+), 29 deletions(-) diff --git a/cmd/api/main.go b/cmd/api/main.go index ef81b04..26ef0cc 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -90,6 +90,7 @@ func main() { router.Method("Post", endpoint.Register, http.HandlerFunc(userHandler.Register)) router.Method("Post", endpoint.Login, http.HandlerFunc(loginHandler.Login)) router.Method("Post", endpoint.CreateServiceUser, http.HandlerFunc(serviceHandler.Register)) + router.Method("Post", endpoint.LoginServiceUser, http.HandlerFunc(serviceHandler.Login)) router.Method("GET", "/swagger/*", httpSwagger.Handler( httpSwagger.URL(fmt.Sprintf("http://localhost:%s/swagger/doc.json", config.Port)), diff --git a/cmd/api/main_test.go b/cmd/api/main_test.go index 8ec80e2..e33288f 100644 --- a/cmd/api/main_test.go +++ b/cmd/api/main_test.go @@ -48,6 +48,7 @@ func TestMain(m *testing.M) { testRouter.HandleFunc(endpoint.Register, userHandler.Register).Methods("POST") testRouter.HandleFunc(endpoint.Login, loginHandler.Login).Methods("POST") testRouter.HandleFunc(endpoint.CreateServiceUser, serviceHandler.Register).Methods("POST") + testRouter.HandleFunc(endpoint.LoginServiceUser, serviceHandler.Login).Methods("POST") code := m.Run() os.Exit(code) diff --git a/internal/handler/endpoint/endpoint.go b/internal/handler/endpoint/endpoint.go index 2cb8b3b..a1734aa 100644 --- a/internal/handler/endpoint/endpoint.go +++ b/internal/handler/endpoint/endpoint.go @@ -4,3 +4,4 @@ package endpoint const Register = "/api/v1/register" const Login = "/api/v1/login" const CreateServiceUser = "/api/v1/service/register" +const LoginServiceUser = "/api/v1/service/login" diff --git a/internal/handler/service.go b/internal/handler/service.go index 8745be6..ebd2f9c 100644 --- a/internal/handler/service.go +++ b/internal/handler/service.go @@ -3,12 +3,22 @@ package handler import ( "net/http" + "git.kundeng.us/phoenix/textsender-models/pkg/token" "git.kundeng.us/phoenix/textsender-models/pkg/user" + "git.kundeng.us/phoenix/textsender-auth/internal/config" "git.kundeng.us/phoenix/textsender-auth/internal/store" "git.kundeng.us/phoenix/textsender-auth/internal/utility" ) +type ServiceHandler struct { + ServiceStore store.ServiceStore +} + +func NewServiceHandler(serviceStore store.ServiceStore) *ServiceHandler { + return &ServiceHandler{ServiceStore: serviceStore} +} + type ServiceCreationRequest struct { Username string `json:"username"` Passphrase string `json:"passphrase"` @@ -19,14 +29,6 @@ type ServiceCreationResponse struct { Data []*user.ServiceUser `json:"data"` } -type ServiceHandler struct { - ServiceStore store.ServiceStore -} - -func NewServiceHandler(serviceStore store.ServiceStore) *ServiceHandler { - return &ServiceHandler{ServiceStore: serviceStore} -} - // Register godoc // @Summary Register service user // @Description Create a service user that can send texts (requires JWT) @@ -80,3 +82,76 @@ func (s *ServiceHandler) Register(w http.ResponseWriter, r *http.Request) { RespondWithJson(w, statusCode, &resp) } + +type ServiceLoginRequest struct { + Username string `json:"username"` + Passphrase string `json:"passphrase"` +} + +type ServiceLoginResponse struct { + Message string `json:"message"` + Data []*token.Login `json:"data"` +} + +// Login godoc +// @Summary Service login +// @Description Servce login and be given an access token (requires JWT) +// @Tags service users +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body ServiceLoginRequest true "Data to obtain a service token" +// @Success 200 {object} ServiceLoginResponse +// @Failure 500 {object} ServiceLoginResponse +// @Router /service/login [post] +func (s *ServiceHandler) Login(w http.ResponseWriter, r *http.Request) { + var req ServiceLoginRequest + 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 ServiceLoginResponse + + if len(req.Username) == 0 || len(req.Passphrase) == 0 { + statusCode = http.StatusBadRequest + resp.Message = "Invalid request" + RespondWithJson(w, statusCode, &resp) + return + } + + ctx := r.Context() + + if serviceUser, err := s.ServiceStore.GetWithUsername(ctx, req.Username); err != nil { + statusCode = http.StatusInternalServerError + resp.Message = err.Error() + } else { + if serviceUser == nil { + statusCode = http.StatusNotFound + resp.Message = "Not found" + } else { + hashing := utility.HashMash{Password: req.Passphrase} + if !hashing.CheckPasswordHash(req.Passphrase, serviceUser.Passphrase) { + statusCode = http.StatusInternalServerError + resp.Message = "Not valid" + } else { + var tokGen utility.TokenGenerator + tokGen.SetHourOffset(8) + secretKey := config.GetSecretKey() + tokGen.SetSecretKey(secretKey) + + if myToken, err := tokGen.GenerateToken(*serviceUser); err != nil { + statusCode = http.StatusInternalServerError + resp.Message = err.Error() + } else { + statusCode = http.StatusOK + resp.Data = append(resp.Data, myToken) + resp.Message = "Successful" + } + } + } + } + + RespondWithJson(w, statusCode, &resp) +} diff --git a/internal/handler/service_test.go b/internal/handler/service_test.go index 5fe737b..560b6bf 100644 --- a/internal/handler/service_test.go +++ b/internal/handler/service_test.go @@ -7,10 +7,12 @@ import ( "strings" "testing" + "git.kundeng.us/phoenix/textsender-models/pkg/user" "github.com/stretchr/testify/assert" "git.kundeng.us/phoenix/textsender-auth/internal/handler/endpoint" "git.kundeng.us/phoenix/textsender-auth/internal/store/mock" + "git.kundeng.us/phoenix/textsender-auth/internal/utility" ) func TestCreateServiceUserWithMock(t *testing.T) { @@ -31,3 +33,38 @@ func TestCreateServiceUserWithMock(t *testing.T) { err = json.Unmarshal(rr.Body.Bytes(), &response) assert.NoError(t, err) } + +func TestLoginServiceUserWithMock(t *testing.T) { + var serviceUser user.ServiceUser + var hashedPassword string + var err error + unhashed := "9328nr29nudx3292m320!" + hashing := utility.HashMash{Password: unhashed} + if hashedPassword, err = hashing.HashPassword(); err != nil { + assert.NoError(t, err, "Error hashing password: %v", err) + } else { + serviceUser.Passphrase = hashedPassword + } + serviceUser.Username = "swoon" + ctx := t.Context() + mockStore := mock.NewMockServiceUserStore() + + if err := mockStore.Create(ctx, &serviceUser); err != nil { + assert.NoError(t, err, "Error creating service user: %v", err) + } + + handler := NewServiceHandler(mockStore) + testService := ServiceLoginRequest{Username: serviceUser.Username, Passphrase: unhashed} + jsonValue, err := json.Marshal(testService) + assert.NoError(t, err, "Error marshaling request") + + req, _ := http.NewRequest("POST", endpoint.LoginServiceUser, strings.NewReader(string(jsonValue))) + rr := httptest.NewRecorder() + + handler.Login(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + + var response ServiceLoginResponse + err = json.Unmarshal(rr.Body.Bytes(), &response) + assert.NoError(t, err) +} diff --git a/internal/store/mock/service_store.go b/internal/store/mock/service_store.go index fb7eb70..a44173a 100644 --- a/internal/store/mock/service_store.go +++ b/internal/store/mock/service_store.go @@ -3,11 +3,11 @@ package mock import ( "context" "errors" + "fmt" "sync" - "github.com/google/uuid" - "git.kundeng.us/phoenix/textsender-models/pkg/user" + "github.com/google/uuid" ) type MockServiceUserStore struct { @@ -53,10 +53,6 @@ func (m *MockServiceUserStore) CheckWithUsername(ctx context.Context, username s return false, m.Error } - if m.Error != nil { - return false, m.Error - } - var exists bool for _, serviceUser := range m.ServiceUsers { @@ -72,3 +68,19 @@ func (m *MockServiceUserStore) CheckWithUsername(ctx context.Context, username s return exists, nil } } + +func (m *MockServiceUserStore) GetWithUsername(ctx context.Context, username string) (*user.ServiceUser, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if m.Error != nil { + return nil, m.Error + } + + serviceUser := m.ServiceUsersByUsername[username] + if serviceUser != nil { + return serviceUser, nil + } else { + return nil, fmt.Errorf("User not found") + } +} diff --git a/internal/store/service.go b/internal/store/service.go index 8a08ba2..2d0f413 100644 --- a/internal/store/service.go +++ b/internal/store/service.go @@ -11,6 +11,7 @@ import ( type ServiceStore interface { CheckWithUsername(ctx context.Context, username string) (bool, error) + GetWithUsername(ctx context.Context, username string) (*user.ServiceUser, error) Create(ctx context.Context, serviceUser *user.ServiceUser) error } @@ -37,6 +38,21 @@ func (s *PGServiceStore) CheckWithUsername(ctx context.Context, username string) } } +func (s *PGServiceStore) GetWithUsername(ctx context.Context, username string) (*user.ServiceUser, error) { + var serviceUser user.ServiceUser + query := `SELECT id, username, passphrase, created FROM service_users WHERE username = $1` + + if err := s.db.QueryRow(ctx, query, username).Scan(&serviceUser.Id, &serviceUser.Username, &serviceUser.Passphrase, &serviceUser.Created); err != nil { + if err == pgx.ErrNoRows { + return nil, nil + } else { + return nil, fmt.Errorf("Error querying row: %v", err) + } + } else { + return &serviceUser, nil + } +} + func (s *PGServiceStore) Create(ctx context.Context, serviceUser *user.ServiceUser) error { query := ` INSERT INTO service_users (username, passphrase) diff --git a/internal/utility/hashing.go b/internal/utility/hashing.go index 2bc324e..2f42ed9 100644 --- a/internal/utility/hashing.go +++ b/internal/utility/hashing.go @@ -8,12 +8,12 @@ type HashMash struct { Password string } -func (h HashMash) HashPassword() (string, error) { +func (h *HashMash) HashPassword() (string, error) { bytes, err := bcrypt.GenerateFromPassword([]byte(h.Password), bcrypt.DefaultCost) return string(bytes), err } -func (h HashMash) CheckPasswordHash(password string, hash string) bool { +func (h *HashMash) CheckPasswordHash(password string, hash string) bool { err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) return err == nil } diff --git a/internal/utility/token.go b/internal/utility/token.go index 00bfc36..b1d138b 100644 --- a/internal/utility/token.go +++ b/internal/utility/token.go @@ -1,8 +1,10 @@ package utility import ( + "fmt" "time" + "github.com/google/uuid" "github.com/golang-jwt/jwt/v5" "git.kundeng.us/phoenix/textsender-auth/internal/config" @@ -15,33 +17,64 @@ const TOKEN_TYPE = "Bearer" type TokenGenerator struct { SecretKey []byte + hourOffset time.Duration } func (t *TokenGenerator) SetSecretKey(secretKey string) { t.SecretKey = []byte(secretKey) } -func (t *TokenGenerator) GenerateToken(user user.User) (*token.Login, error) { - issuedAt := time.Now() - expirationTime := time.Now().Add(4 * time.Hour) - claims := t.generateClaims(user, TOKEN_TYPE, issuedAt, expirationTime) - - myToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - if tokenString, err := myToken.SignedString(t.SecretKey); err != nil { - return nil, err +func (t *TokenGenerator) SetHourOffset(offset time.Duration) error { + if offset < 48 { + t.hourOffset = offset + return nil } else { - return &token.Login{AccessToken: tokenString, TokenType: TOKEN_TYPE, ExpiresIn: expirationTime.Unix()}, nil + return fmt.Errorf("No change") } } -func (t *TokenGenerator) generateClaims(user user.User, role string, issuedAt time.Time, expiredAt time.Time) token.Claims { - return token.Claims{ - UserId: user.Id, +func (t *TokenGenerator) GenerateToken(usr any) (*token.Login, error) { + issuedAt := time.Now() + if t.hourOffset == 0 { + t.hourOffset = 4 + } + + expirationTime := time.Now().Add(t.hourOffset * time.Hour) + + if claims, err := t.generateClaims(usr, TOKEN_TYPE, issuedAt, expirationTime); err != nil { + return nil, fmt.Errorf("Error generating claims: %v", err) + } else { + myToken := jwt.NewWithClaims(jwt.SigningMethodHS256, *claims) + if tokenString, err := myToken.SignedString(t.SecretKey); err != nil { + return nil, err + } else { + return &token.Login{AccessToken: tokenString, TokenType: TOKEN_TYPE, ExpiresIn: expirationTime.Unix()}, nil + } + } +} + +func (t *TokenGenerator) generateClaims(usr any, role string, issuedAt time.Time, expiredAt time.Time) (*token.Claims, error) { + var id uuid.UUID + switch val := usr.(type) { + case user.User: + id = val.Id + case *user.User: + id = val.Id + case user.ServiceUser: + id = val.Id + case *user.ServiceUser: + id = val.Id + default: + return nil, fmt.Errorf("Invalid type") + } + + return &token.Claims{ + UserId: id, Role: role, RegisteredClaims: jwt.RegisteredClaims{ Issuer: config.App_Name, ExpiresAt: jwt.NewNumericDate(expiredAt), IssuedAt: jwt.NewNumericDate(issuedAt), }, - } + }, nil }