Compare commits

..

No commits in common. "7ff91bab1d380e3427ab46d2c8fd870448c13a61" and "71926b2197bdb9967d9e97ce7ddc0d3aaf8d1879" have entirely different histories.

9 changed files with 9 additions and 441 deletions

View File

@ -1,7 +1,7 @@
services:
postgres:
image: postgres:alpine
image: postgres
restart: always
ports:
- "5432:5432"
@ -20,15 +20,3 @@ services:
restart: always
ports:
- 8080:8080
redis:
image: redis:alpine
restart: always
ports:
- "6379:6379"
deploy:
mode: replicated
replicas: 1
command: redis-server --save 20 1 --loglevel warning
volumes:
- ../../db_data_howmuch/redis/:/data

1
go.mod
View File

@ -6,7 +6,6 @@ require (
github.com/fsnotify/fsnotify v1.7.0
github.com/gin-contrib/cors v1.7.2
github.com/gin-gonic/gin v1.10.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
github.com/gosuri/uitable v0.0.4
github.com/jackc/pgx/v5 v5.7.1

2
go.sum
View File

@ -35,8 +35,6 @@ github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27
github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=

View File

@ -1,34 +1,7 @@
// MIT License
//
// Copyright (c) 2024 vinchent <vinchent@vinchent.xyz>
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package controller
import (
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/model"
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/usecase/usecase"
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/core"
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/errno"
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/log"
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/token"
"github.com/gin-gonic/gin"
)
@ -36,55 +9,12 @@ type Session interface {
Create(*gin.Context)
}
type SessionController struct {
userUsecase usecase.User
type SessionController struct{}
func NewSessionController() Session {
return &SessionController{}
}
func NewSessionController(u usecase.User) Session {
return &SessionController{u}
}
type Token struct {
Token string `json:"token"`
}
type createParams struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
}
// Create creates a session for a user and returns a token
//
// Since we use JWT method, this token is not stored anywhere. Thus it
// stops at the controller level.
func (sc *SessionController) Create(ctx *gin.Context) {
var params createParams
if err := ctx.Bind(&params); err != nil {
log.ErrorLog("param error", "err", err)
core.WriteResponse(ctx, UserParamsErr, nil)
return
}
user := model.User{
Email: params.Email,
Password: params.Password,
}
err := sc.userUsecase.Exist(ctx, &user)
if err != nil {
core.WriteResponse(ctx, err, nil)
return
}
// user exists. Generate the token for the user
tokenString, err := token.Sign(user.Email)
if err != nil {
core.WriteResponse(ctx, errno.InternalServerErr, nil)
return
}
core.WriteResponse(ctx, nil, Token{
Token: tokenString,
})
log.CtxLog(ctx).DebugLog("session create")
}

View File

@ -1,95 +0,0 @@
// MIT License
//
// Copyright (c) 2024 vinchent <vinchent@vinchent.xyz>
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package controller
import (
"bytes"
"encoding/json"
"net/http"
"testing"
"time"
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/adapter/controller/usecasemock"
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/usecase/usecase"
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/errno"
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/test"
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/token"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
)
func TestSessionCreate(t *testing.T) {
tests := []struct {
Name string
User createParams
Errno *errno.Errno
}{
{"registered user", createParams{
Email: "correct@correct.com",
Password: "strong password",
}, errno.OK},
{"unregistered user", createParams{
Email: "unregistered@error.com",
Password: "strong password",
}, usecase.UserNotExist},
}
token.Init("secret", 1*time.Second)
for _, tst := range tests {
t.Run(tst.Name, func(t *testing.T) {
testUserUsecase := usecasemock.NewtestUserUsecase()
sessionController := NewSessionController(testUserUsecase)
r := gin.New()
r.POST(
"/session/create",
func(ctx *gin.Context) { sessionController.Create(ctx) },
)
user, _ := json.Marshal(tst.User)
res := test.PerformRequest(t, r, "POST", "/session/create", bytes.NewReader(user),
test.Header{
Key: "content-type",
Value: "application/json",
})
assert.Equal(t, tst.Errno.HTTP, res.Result().StatusCode, res.Body)
if tst.Errno.HTTP != http.StatusOK {
var got errno.Errno
err := json.NewDecoder(res.Result().Body).Decode(&got)
// XXX: the http status is not in the json. So it must be reset back the the struct
got.HTTP = res.Result().StatusCode
assert.NoError(t, err)
assert.Equal(t, tst.Errno, &got)
return
}
var got Token
err := json.NewDecoder(res.Result().Body).Decode(&got)
assert.NoError(t, err)
tkResp, err := token.Parse(got.Token)
assert.NoError(t, err)
assert.Equal(t, tst.User.Email, tkResp.Identity)
})
}
}

View File

@ -1,55 +0,0 @@
// MIT License
//
// Copyright (c) 2024 vinchent <vinchent@vinchent.xyz>
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package usecasemock
import (
"context"
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/model"
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/usecase/usecase"
)
type testUserUsecase struct{}
func NewtestUserUsecase() usecase.User {
return &testUserUsecase{}
}
func (*testUserUsecase) Create(ctx context.Context, u *model.User) (*model.User, error) {
return nil, nil
}
func (*testUserUsecase) Exist(ctx context.Context, u *model.User) error {
switch u.Email {
case "a@b.c":
if u.Password == "strong password" {
return nil
} else {
return usecase.UserWrongPassword
}
case "unregistered@error.com":
return usecase.UserNotExist
}
// Should never reach here
return nil
}

View File

@ -1,35 +1,9 @@
// MIT License
//
// Copyright (c) 2024 vinchent <vinchent@vinchent.xyz>
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package registry
import (
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/adapter/controller"
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/adapter/repo"
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/usecase/usecase"
)
import "git.vinchent.xyz/vinchent/howmuch/internal/howmuch/adapter/controller"
// NewSessionController returns a session controller's implementation
func (r *registry) NewSessionController() controller.Session {
u := usecase.NewUserUsecase(repo.NewUserRepository(r.db), repo.NewDBRepository(r.db))
return controller.NewSessionController(u)
// u := usecase.NewSessionUsecase(repo.NewSessionRepository(r.db), repo.NewDBRepository(r.db))
return controller.NewSessionController()
}

View File

@ -1,56 +0,0 @@
// MIT License
//
// Copyright (c) 2024 vinchent <vinchent@vinchent.xyz>
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package middleware
import (
"net/http"
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/core"
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/errno"
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/token"
"github.com/gin-gonic/gin"
)
var ErrTokenInvalid = &errno.Errno{
HTTP: http.StatusUnauthorized,
Code: errno.ErrorCode(errno.AuthFailureCode, "TokenInvalid"),
Message: "invalid token",
}
const XUserName = "X-Username"
// Authn authenticates a user's access by validating their token.
func Authn() gin.HandlerFunc {
return func(ctx *gin.Context) {
tk, err := token.ParseRequest(ctx)
if err != nil || tk == nil {
core.WriteResponse(ctx, ErrTokenInvalid, nil)
ctx.Abort()
}
// TODO: check if the key is on logout blacklist.
ctx.Header(XUserName, tk.Identity)
ctx.Next()
}
}

View File

@ -1,115 +0,0 @@
// MIT License
//
// Copyright (c) 2024 vinchent <vinchent@vinchent.xyz>
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package token
import (
"errors"
"fmt"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
type Config struct {
secretKey string
expiryTime time.Duration
}
type Claims struct {
IdentityKey string `json:"identity_key"`
jwt.RegisteredClaims
}
type TokenResp struct {
Identity string
Expiry time.Time
}
var (
once sync.Once
config Config
)
var ErrMissingHeader = errors.New("Authorization is needed in the header")
func Init(secretKey string, expiryTime time.Duration) {
once.Do(func() {
config.secretKey = secretKey
config.expiryTime = expiryTime
})
}
func Sign(identityKey string) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, Claims{
identityKey,
jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(config.expiryTime)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
},
})
return token.SignedString([]byte(config.secretKey))
}
func Parse(tokenString string) (*TokenResp, error) {
token, err := jwt.ParseWithClaims(
tokenString,
&Claims{},
func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
return []byte(config.secretKey), nil
},
)
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*Claims); ok {
return &TokenResp{
claims.IdentityKey,
claims.ExpiresAt.Time,
}, nil
}
return nil, nil
}
func ParseRequest(c *gin.Context) (*TokenResp, error) {
// NOTE: Authorization: Bearer sdkfjlsfjlskdfjlsjdflk...slkdfjlka
header := c.GetHeader("Authorization")
if len(header) == 0 {
return nil, ErrMissingHeader
}
// get the token
var t string
fmt.Sscanf(header, "Bearer %s", &t)
return Parse(t)
}