tsk-19: Service login (#23)
Closes #19 Reviewed-on: phoenix/textsender-auth#23 Co-authored-by: phoenix <kundeng00@pm.me> Co-committed-by: phoenix <kundeng00@pm.me>
This commit is contained in:
@@ -90,6 +90,7 @@ func main() {
|
|||||||
router.Method("Post", endpoint.Register, http.HandlerFunc(userHandler.Register))
|
router.Method("Post", endpoint.Register, http.HandlerFunc(userHandler.Register))
|
||||||
router.Method("Post", endpoint.Login, http.HandlerFunc(loginHandler.Login))
|
router.Method("Post", endpoint.Login, http.HandlerFunc(loginHandler.Login))
|
||||||
router.Method("Post", endpoint.CreateServiceUser, http.HandlerFunc(serviceHandler.Register))
|
router.Method("Post", endpoint.CreateServiceUser, http.HandlerFunc(serviceHandler.Register))
|
||||||
|
router.Method("Post", endpoint.LoginServiceUser, http.HandlerFunc(serviceHandler.Login))
|
||||||
|
|
||||||
router.Method("GET", "/swagger/*", httpSwagger.Handler(
|
router.Method("GET", "/swagger/*", httpSwagger.Handler(
|
||||||
httpSwagger.URL(fmt.Sprintf("http://localhost:%s/swagger/doc.json", config.Port)),
|
httpSwagger.URL(fmt.Sprintf("http://localhost:%s/swagger/doc.json", config.Port)),
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ func TestMain(m *testing.M) {
|
|||||||
testRouter.HandleFunc(endpoint.Register, userHandler.Register).Methods("POST")
|
testRouter.HandleFunc(endpoint.Register, userHandler.Register).Methods("POST")
|
||||||
testRouter.HandleFunc(endpoint.Login, loginHandler.Login).Methods("POST")
|
testRouter.HandleFunc(endpoint.Login, loginHandler.Login).Methods("POST")
|
||||||
testRouter.HandleFunc(endpoint.CreateServiceUser, serviceHandler.Register).Methods("POST")
|
testRouter.HandleFunc(endpoint.CreateServiceUser, serviceHandler.Register).Methods("POST")
|
||||||
|
testRouter.HandleFunc(endpoint.LoginServiceUser, serviceHandler.Login).Methods("POST")
|
||||||
|
|
||||||
code := m.Run()
|
code := m.Run()
|
||||||
os.Exit(code)
|
os.Exit(code)
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ package endpoint
|
|||||||
const Register = "/api/v1/register"
|
const Register = "/api/v1/register"
|
||||||
const Login = "/api/v1/login"
|
const Login = "/api/v1/login"
|
||||||
const CreateServiceUser = "/api/v1/service/register"
|
const CreateServiceUser = "/api/v1/service/register"
|
||||||
|
const LoginServiceUser = "/api/v1/service/login"
|
||||||
|
|||||||
@@ -3,12 +3,22 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"git.kundeng.us/phoenix/textsender-models/pkg/token"
|
||||||
"git.kundeng.us/phoenix/textsender-models/pkg/user"
|
"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/store"
|
||||||
"git.kundeng.us/phoenix/textsender-auth/internal/utility"
|
"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 {
|
type ServiceCreationRequest struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Passphrase string `json:"passphrase"`
|
Passphrase string `json:"passphrase"`
|
||||||
@@ -19,14 +29,6 @@ type ServiceCreationResponse struct {
|
|||||||
Data []*user.ServiceUser `json:"data"`
|
Data []*user.ServiceUser `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServiceHandler struct {
|
|
||||||
ServiceStore store.ServiceStore
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewServiceHandler(serviceStore store.ServiceStore) *ServiceHandler {
|
|
||||||
return &ServiceHandler{ServiceStore: serviceStore}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register godoc
|
// Register godoc
|
||||||
// @Summary Register service user
|
// @Summary Register service user
|
||||||
// @Description Create a service user that can send texts (requires JWT)
|
// @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)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.kundeng.us/phoenix/textsender-models/pkg/user"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
"git.kundeng.us/phoenix/textsender-auth/internal/handler/endpoint"
|
"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/store/mock"
|
||||||
|
"git.kundeng.us/phoenix/textsender-auth/internal/utility"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCreateServiceUserWithMock(t *testing.T) {
|
func TestCreateServiceUserWithMock(t *testing.T) {
|
||||||
@@ -31,3 +33,38 @@ func TestCreateServiceUserWithMock(t *testing.T) {
|
|||||||
err = json.Unmarshal(rr.Body.Bytes(), &response)
|
err = json.Unmarshal(rr.Body.Bytes(), &response)
|
||||||
assert.NoError(t, err)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ package mock
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
|
|
||||||
"git.kundeng.us/phoenix/textsender-models/pkg/user"
|
"git.kundeng.us/phoenix/textsender-models/pkg/user"
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MockServiceUserStore struct {
|
type MockServiceUserStore struct {
|
||||||
@@ -53,10 +53,6 @@ func (m *MockServiceUserStore) CheckWithUsername(ctx context.Context, username s
|
|||||||
return false, m.Error
|
return false, m.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.Error != nil {
|
|
||||||
return false, m.Error
|
|
||||||
}
|
|
||||||
|
|
||||||
var exists bool
|
var exists bool
|
||||||
|
|
||||||
for _, serviceUser := range m.ServiceUsers {
|
for _, serviceUser := range m.ServiceUsers {
|
||||||
@@ -72,3 +68,19 @@ func (m *MockServiceUserStore) CheckWithUsername(ctx context.Context, username s
|
|||||||
return exists, nil
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
type ServiceStore interface {
|
type ServiceStore interface {
|
||||||
CheckWithUsername(ctx context.Context, username string) (bool, error)
|
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
|
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 {
|
func (s *PGServiceStore) Create(ctx context.Context, serviceUser *user.ServiceUser) error {
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO service_users (username, passphrase)
|
INSERT INTO service_users (username, passphrase)
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ type HashMash struct {
|
|||||||
Password string
|
Password string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h HashMash) HashPassword() (string, error) {
|
func (h *HashMash) HashPassword() (string, error) {
|
||||||
bytes, err := bcrypt.GenerateFromPassword([]byte(h.Password), bcrypt.DefaultCost)
|
bytes, err := bcrypt.GenerateFromPassword([]byte(h.Password), bcrypt.DefaultCost)
|
||||||
return string(bytes), err
|
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))
|
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-13
@@ -1,8 +1,10 @@
|
|||||||
package utility
|
package utility
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
|
||||||
"git.kundeng.us/phoenix/textsender-auth/internal/config"
|
"git.kundeng.us/phoenix/textsender-auth/internal/config"
|
||||||
@@ -15,33 +17,64 @@ const TOKEN_TYPE = "Bearer"
|
|||||||
|
|
||||||
type TokenGenerator struct {
|
type TokenGenerator struct {
|
||||||
SecretKey []byte
|
SecretKey []byte
|
||||||
|
hourOffset time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TokenGenerator) SetSecretKey(secretKey string) {
|
func (t *TokenGenerator) SetSecretKey(secretKey string) {
|
||||||
t.SecretKey = []byte(secretKey)
|
t.SecretKey = []byte(secretKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TokenGenerator) GenerateToken(user user.User) (*token.Login, error) {
|
func (t *TokenGenerator) SetHourOffset(offset time.Duration) error {
|
||||||
issuedAt := time.Now()
|
if offset < 48 {
|
||||||
expirationTime := time.Now().Add(4 * time.Hour)
|
t.hourOffset = offset
|
||||||
claims := t.generateClaims(user, TOKEN_TYPE, issuedAt, expirationTime)
|
return nil
|
||||||
|
|
||||||
myToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
||||||
if tokenString, err := myToken.SignedString(t.SecretKey); err != nil {
|
|
||||||
return nil, err
|
|
||||||
} else {
|
} 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 {
|
func (t *TokenGenerator) GenerateToken(usr any) (*token.Login, error) {
|
||||||
return token.Claims{
|
issuedAt := time.Now()
|
||||||
UserId: user.Id,
|
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,
|
Role: role,
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
Issuer: config.App_Name,
|
Issuer: config.App_Name,
|
||||||
ExpiresAt: jwt.NewNumericDate(expiredAt),
|
ExpiresAt: jwt.NewNumericDate(expiredAt),
|
||||||
IssuedAt: jwt.NewNumericDate(issuedAt),
|
IssuedAt: jwt.NewNumericDate(issuedAt),
|
||||||
},
|
},
|
||||||
}
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user