diff --git a/cmd/api/main.go b/cmd/api/main.go index 26ef0cc..fc87618 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -79,6 +79,7 @@ func main() { userHandler := handler.NewUserHandler(userStore) loginHandler := handler.NewLoginHandler(userStore) serviceHandler := handler.NewServiceHandler(serviceStore) + refreshHandler := handler.NewRefreshHandler(userStore, serviceStore) router := chi.NewRouter() @@ -91,6 +92,7 @@ func main() { 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("Post", endpoint.TokenRefresh, http.HandlerFunc(refreshHandler.Refresh)) 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 e33288f..f3f2eb7 100644 --- a/cmd/api/main_test.go +++ b/cmd/api/main_test.go @@ -43,12 +43,14 @@ func TestMain(m *testing.M) { userHandler := handler.NewUserHandler(userStore) loginHandler := handler.NewLoginHandler(userStore) serviceHandler := handler.NewServiceHandler(serviceStore) + refreshHandler := handler.NewRefreshHandler(userStore, serviceStore) testRouter = mux.NewRouter() 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") + testRouter.HandleFunc(endpoint.TokenRefresh, refreshHandler.Refresh).Methods("POST") code := m.Run() os.Exit(code) diff --git a/docs/docs.go b/docs/docs.go index 3ffc603..f38fce9 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -117,6 +117,51 @@ const docTemplate = `{ } } }, + "/service/login": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Servce login and be given an access token (requires JWT)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "service users" + ], + "summary": "Service login", + "parameters": [ + { + "description": "Data to obtain a service token", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.ServiceLoginRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.ServiceLoginResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handler.ServiceLoginResponse" + } + } + } + } + }, "/service/register": { "post": { "security": [ @@ -167,6 +212,57 @@ const docTemplate = `{ } } } + }, + "/token/refresh": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Refresh token endpoint (requires JWT)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "refresh" + ], + "summary": "Obtain a refresh token", + "parameters": [ + { + "description": "Data to refresh token", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.RefreshRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RefreshResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handler.RefreshResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handler.RefreshResponse" + } + } + } + } } }, "definitions": { @@ -195,6 +291,28 @@ const docTemplate = `{ } } }, + "handler.RefreshRequest": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + } + } + }, + "handler.RefreshResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/token.Login" + } + }, + "message": { + "type": "string" + } + } + }, "handler.RegisterResponse": { "type": "object", "properties": { @@ -262,6 +380,31 @@ const docTemplate = `{ } } }, + "handler.ServiceLoginRequest": { + "type": "object", + "properties": { + "passphrase": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "handler.ServiceLoginResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/token.Login" + } + }, + "message": { + "type": "string" + } + } + }, "token.Login": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 922c622..892e61b 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -111,6 +111,51 @@ } } }, + "/service/login": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Servce login and be given an access token (requires JWT)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "service users" + ], + "summary": "Service login", + "parameters": [ + { + "description": "Data to obtain a service token", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.ServiceLoginRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.ServiceLoginResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handler.ServiceLoginResponse" + } + } + } + } + }, "/service/register": { "post": { "security": [ @@ -161,6 +206,57 @@ } } } + }, + "/token/refresh": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Refresh token endpoint (requires JWT)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "refresh" + ], + "summary": "Obtain a refresh token", + "parameters": [ + { + "description": "Data to refresh token", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.RefreshRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RefreshResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handler.RefreshResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handler.RefreshResponse" + } + } + } + } } }, "definitions": { @@ -189,6 +285,28 @@ } } }, + "handler.RefreshRequest": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + } + } + }, + "handler.RefreshResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/token.Login" + } + }, + "message": { + "type": "string" + } + } + }, "handler.RegisterResponse": { "type": "object", "properties": { @@ -256,6 +374,31 @@ } } }, + "handler.ServiceLoginRequest": { + "type": "object", + "properties": { + "passphrase": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "handler.ServiceLoginResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/token.Login" + } + }, + "message": { + "type": "string" + } + } + }, "token.Login": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 1ef23cb..7073873 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -16,6 +16,20 @@ definitions: message: type: string type: object + handler.RefreshRequest: + properties: + access_token: + type: string + type: object + handler.RefreshResponse: + properties: + data: + items: + $ref: '#/definitions/token.Login' + type: array + message: + type: string + type: object handler.RegisterResponse: properties: data: @@ -59,6 +73,22 @@ definitions: message: type: string type: object + handler.ServiceLoginRequest: + properties: + passphrase: + type: string + username: + type: string + type: object + handler.ServiceLoginResponse: + properties: + data: + items: + $ref: '#/definitions/token.Login' + type: array + message: + type: string + type: object token.Login: properties: access_token: @@ -150,6 +180,34 @@ paths: summary: Register user tags: - users + /service/login: + post: + consumes: + - application/json + description: Servce login and be given an access token (requires JWT) + parameters: + - description: Data to obtain a service token + in: body + name: request + required: true + schema: + $ref: '#/definitions/handler.ServiceLoginRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.ServiceLoginResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handler.ServiceLoginResponse' + security: + - BearerAuth: [] + summary: Service login + tags: + - service users /service/register: post: consumes: @@ -182,6 +240,38 @@ paths: summary: Register service user tags: - service users + /token/refresh: + post: + consumes: + - application/json + description: Refresh token endpoint (requires JWT) + parameters: + - description: Data to refresh token + in: body + name: request + required: true + schema: + $ref: '#/definitions/handler.RefreshRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.RefreshResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handler.RefreshResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handler.RefreshResponse' + security: + - BearerAuth: [] + summary: Obtain a refresh token + tags: + - refresh securityDefinitions: BearerAuth: description: JWT Bearer Token diff --git a/internal/handler/endpoint/endpoint.go b/internal/handler/endpoint/endpoint.go index a1734aa..37f2bdf 100644 --- a/internal/handler/endpoint/endpoint.go +++ b/internal/handler/endpoint/endpoint.go @@ -5,3 +5,4 @@ const Register = "/api/v1/register" const Login = "/api/v1/login" const CreateServiceUser = "/api/v1/service/register" const LoginServiceUser = "/api/v1/service/login" +const TokenRefresh = "/api/v1/token/refresh" diff --git a/internal/handler/refresh.go b/internal/handler/refresh.go new file mode 100644 index 0000000..920d152 --- /dev/null +++ b/internal/handler/refresh.go @@ -0,0 +1,100 @@ +package handler + +import ( + "net/http" + + "git.kundeng.us/phoenix/textsender-models/pkg/token" + + "git.kundeng.us/phoenix/textsender-auth/internal/config" + "git.kundeng.us/phoenix/textsender-auth/internal/model" + "git.kundeng.us/phoenix/textsender-auth/internal/store" + "git.kundeng.us/phoenix/textsender-auth/internal/utility" +) + +type RefreshHandler struct { + UserStore model.UserStore + ServiceStore store.ServiceStore +} + +func NewRefreshHandler(userStore model.UserStore, serviceStore store.ServiceStore) *RefreshHandler { + return &RefreshHandler{UserStore: userStore, ServiceStore: serviceStore} +} + +type RefreshRequest struct { + AccessToken string `json:"access_token"` +} + +type RefreshResponse struct { + Message string `json:"message"` + Data []*token.Login `json:"data"` +} + +// Refresh godoc +// @Summary Obtain a refresh token +// @Description Refresh token endpoint (requires JWT) +// @Tags refresh +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body RefreshRequest true "Data to refresh token" +// @Success 200 {object} RefreshResponse +// @Failure 400 {object} RefreshResponse +// @Failure 500 {object} RefreshResponse +// @Router /token/refresh [post] +func (rh *RefreshHandler) Refresh(w http.ResponseWriter, r *http.Request) { + var req RefreshRequest + 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 RefreshResponse + + secretKey := config.GetSecretKey() + tokGen := utility.TokenGenerator{} + tokGen.SetSecretKey(secretKey) + tokGen.SetHourOffset(12) + if verified, err := tokGen.VerifyToken(req.AccessToken); err != nil { + statusCode = http.StatusInternalServerError + resp.Message = err.Error() + } else { + if verified { + if id, err := tokGen.ExtractIdFromToken(req.AccessToken); err != nil { + statusCode = http.StatusInternalServerError + resp.Message = err.Error() + } else { + ctx := r.Context() + if usr, err := rh.UserStore.GetUserByID(ctx, id); err != nil || usr == nil { + if serviceUsr, err := rh.ServiceStore.GetWithId(ctx, id); err != nil || serviceUsr == nil { + statusCode = http.StatusInternalServerError + resp.Message = err.Error() + } else { + if myToken, err := tokGen.GenerateToken(serviceUsr); err != nil { + statusCode = http.StatusInternalServerError + resp.Message = err.Error() + } else { + statusCode = http.StatusOK + resp.Data = append(resp.Data, myToken) + resp.Message = "Successful" + } + } + } else { + if myToken, err := tokGen.GenerateToken(usr); err != nil { + statusCode = http.StatusInternalServerError + resp.Message = err.Error() + } else { + statusCode = http.StatusOK + resp.Data = append(resp.Data, myToken) + resp.Message = "Successful" + } + } + } + } else { + statusCode = http.StatusBadRequest + resp.Message = "Unverified" + } + } + + RespondWithJson(w, statusCode, &resp) +} diff --git a/internal/handler/refresh_test.go b/internal/handler/refresh_test.go new file mode 100644 index 0000000..346731d --- /dev/null +++ b/internal/handler/refresh_test.go @@ -0,0 +1,65 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "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 TestRefreshTokenWithMock(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() + userStore := mock.NewMockUserStore() + + 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) + + accessToken := response.Data[0].AccessToken + + testReq := RefreshRequest{AccessToken: accessToken} + jsonValue, err = json.Marshal(testReq) + assert.NoError(t, err, "Error marshaling request") + + newHandler := NewRefreshHandler(userStore, mockStore) + req, _ = http.NewRequest("POST", endpoint.TokenRefresh, strings.NewReader(string(jsonValue))) + rr = httptest.NewRecorder() + + newHandler.Refresh(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) +} diff --git a/internal/store/mock/service_store.go b/internal/store/mock/service_store.go index a44173a..7c8c146 100644 --- a/internal/store/mock/service_store.go +++ b/internal/store/mock/service_store.go @@ -84,3 +84,20 @@ func (m *MockServiceUserStore) GetWithUsername(ctx context.Context, username str return nil, fmt.Errorf("User not found") } } + +func (m *MockServiceUserStore) GetWithId(ctx context.Context, id uuid.UUID) (*user.ServiceUser, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if m.Error != nil { + return nil, m.Error + } + + for _, serviceUser := range m.ServiceUsers { + if serviceUser.Id == id { + return serviceUser, nil + } + } + + return nil, nil +} diff --git a/internal/store/service.go b/internal/store/service.go index 2d0f413..2a79c6f 100644 --- a/internal/store/service.go +++ b/internal/store/service.go @@ -5,6 +5,7 @@ import ( "fmt" "git.kundeng.us/phoenix/textsender-models/pkg/user" + "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) @@ -12,6 +13,7 @@ import ( type ServiceStore interface { CheckWithUsername(ctx context.Context, username string) (bool, error) GetWithUsername(ctx context.Context, username string) (*user.ServiceUser, error) + GetWithId(ctx context.Context, id uuid.UUID) (*user.ServiceUser, error) Create(ctx context.Context, serviceUser *user.ServiceUser) error } @@ -53,6 +55,21 @@ func (s *PGServiceStore) GetWithUsername(ctx context.Context, username string) ( } } +func (s *PGServiceStore) GetWithId(ctx context.Context, id uuid.UUID) (*user.ServiceUser, error) { + var serviceUser user.ServiceUser + query := `SELECT id, username, passphrase, created FROM service_users WHERE id = $1` + + if err := s.db.QueryRow(ctx, query, id).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/token.go b/internal/utility/token.go index b1d138b..81733e5 100644 --- a/internal/utility/token.go +++ b/internal/utility/token.go @@ -53,6 +53,66 @@ func (t *TokenGenerator) GenerateToken(usr any) (*token.Login, error) { } } +func (t *TokenGenerator) VerifyToken(accessToken string) (bool, error) { + clms := &token.Claims{} + + if tken, err := t.parseTokenWithClaims(accessToken, clms); err != nil { + return false, nil + } else { + if tken.Valid { + if clms != nil { + if clms.UserId != uuid.Nil { + return true, nil + } else { + return false, fmt.Errorf("User Id was not set") + } + } else { + return false, fmt.Errorf("Claims not parsed") + } + } else { + return false, fmt.Errorf("Invalid access token") + } + } +} + +func (t *TokenGenerator) ExtractIdFromToken(accessToken string) (uuid.UUID, error) { + clms := &token.Claims{} + + if tken, err := t.parseTokenWithClaims(accessToken, clms); err != nil { + return uuid.Nil, nil + } else { + if tken.Valid { + if clms != nil { + if clms.UserId != uuid.Nil { + return clms.UserId, nil + } else { + return uuid.Nil, fmt.Errorf("User Id was not set") + } + } else { + return uuid.Nil, fmt.Errorf("Claims not parsed") + } + } else { + return uuid.Nil, fmt.Errorf("Invalid access token") + } + } +} + + +func (t *TokenGenerator) parseTokenWithClaims(accessToken string, claims *token.Claims) (*jwt.Token, error) { + tken, err := jwt.ParseWithClaims(accessToken, claims, func(tken *jwt.Token) (any, error) { + if _, ok := tken.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", tken.Header["alg"]) + } + return t.SecretKey, nil + }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()})) + + if err != nil { + return nil, err + } else { + return tken, 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) {