feat(user): migrate from gRPC to REST

This commit is contained in:
Vyacheslav1557 2025-02-25 18:33:15 +05:00
parent 2b94be99e3
commit 7b8d15a2c9
20 changed files with 977 additions and 1150 deletions

View file

@ -1,19 +1,9 @@
tag = latest
gen:
@protoc --proto_path=proto --go_opt=paths=source_relative \
--go_out=proto --go-grpc_out=proto --grpc-gateway_out=proto \
proto/user/v1/user.proto
gen-openapi:
@protoc --proto_path=proto --openapi_out=proto/user/v1 \
proto/user/v1/user.proto
dev:
@make gen
@oapi-codegen --config=config.yaml ./proto/user/v1/openapi.yaml
dev: gen
@go run cmd/ms-auth/main.go
proxy:
@make gen
@go run cmd/ms-auth-proxy/main.go
build:
@make gen
build: gen
@docker build . -t ms-auth:${tag}
@#docker push ms-auth:${tag}

View file

@ -1,144 +0,0 @@
package main
import (
"context"
"errors"
"fmt"
"git.sch9.ru/new_gate/ms-auth/config"
delivery "git.sch9.ru/new_gate/ms-auth/internal/users/delivery/grpc"
userv1gw "git.sch9.ru/new_gate/ms-auth/proto/user/v1"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"github.com/ilyakaznacheev/cleanenv"
"github.com/rs/cors"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func CustomOutgoingHeaderMatcher(key string) (string, bool) {
if key == delivery.SessionHeaderName {
return "Set-Cookie", true
}
return fmt.Sprintf("%s%s", runtime.MetadataHeaderPrefix, key), true
}
func CustomIncomingHeaderMatcher(key string) (string, bool) {
if key == "Cookie" {
return "Cookie", true
}
return fmt.Sprintf("%s%s", runtime.MetadataPrefix, key), true
}
func UnaryClientInterceptor(
ctx context.Context,
method string,
req, reply interface{},
cc *grpc.ClientConn,
invoker grpc.UnaryInvoker,
opts ...grpc.CallOption,
) error {
md, ok := metadata.FromOutgoingContext(ctx)
hasCookie := ok && len(md.Get("Cookie")) > 0
if hasCookie {
cookies, err := http.ParseCookie(md.Get("Cookie")[0])
if err != nil {
return err
}
if len(cookies) != 1 {
return errors.New("invalid cookie")
}
md.Set(delivery.SessionHeaderName, cookies[0].Value)
}
ctx = metadata.NewOutgoingContext(ctx, md)
err := invoker(ctx, method, req, reply, cc, opts...)
if err != nil {
return err
}
for _, o := range opts {
header, ok := o.(grpc.HeaderCallOption)
if !ok {
continue
}
values := header.HeaderAddr.Get(delivery.SessionHeaderName)
if len(values) != 1 {
continue
}
sessionId := values[0]
cookie := http.Cookie{
Name: "SESSIONID",
Value: sessionId,
Path: "/",
HttpOnly: true,
}
if len(sessionId) == 0 {
cookie.Expires = time.Unix(0, 0)
} else {
cookie.MaxAge = 3600
}
header.HeaderAddr.Set(delivery.SessionHeaderName, cookie.String())
}
return nil
}
func main() {
var cfg config.Config
err := cleanenv.ReadConfig(".env", &cfg)
if err != nil {
panic(fmt.Sprintf("error reading config: %s", err.Error()))
}
ctx := context.Background()
ctx, cancel := context.WithCancel(ctx)
defer cancel()
mux := runtime.NewServeMux(runtime.WithOutgoingHeaderMatcher(CustomOutgoingHeaderMatcher), runtime.WithIncomingHeaderMatcher(CustomIncomingHeaderMatcher))
opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithChainUnaryInterceptor(UnaryClientInterceptor)}
err = userv1gw.RegisterUserServiceHandlerFromEndpoint(ctx, mux, cfg.Address, opts)
if err != nil {
panic(err)
}
c := cors.New(cors.Options{
AllowedOrigins: []string{"http://*", "https://*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Content-Type", "Set-Cookie", "Credentials"},
ExposedHeaders: []string{"Link"},
AllowCredentials: true,
})
go func() {
err = http.ListenAndServe(cfg.ProxyAddress, c.Handler(mux))
if err != nil {
panic(err)
}
}()
fmt.Println("server proxy started")
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGTERM, syscall.SIGINT)
<-stop
return
}

View file

@ -5,17 +5,16 @@ import (
"fmt"
"git.sch9.ru/new_gate/ms-auth/config"
"git.sch9.ru/new_gate/ms-auth/internal/models"
usersDelivery "git.sch9.ru/new_gate/ms-auth/internal/users/delivery/grpc"
"git.sch9.ru/new_gate/ms-auth/internal/users/delivery/rest"
usersRepository "git.sch9.ru/new_gate/ms-auth/internal/users/repository"
usersUseCase "git.sch9.ru/new_gate/ms-auth/internal/users/usecase"
"git.sch9.ru/new_gate/ms-auth/pkg"
"github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/logging"
userv1 "git.sch9.ru/new_gate/ms-auth/proto/user/v1"
"github.com/gofiber/fiber/v2"
fiberlogger "github.com/gofiber/fiber/v2/middleware/logger"
"github.com/ilyakaznacheev/cleanenv"
_ "github.com/jackc/pgx/v5/stdlib"
"go.uber.org/zap"
"google.golang.org/grpc"
"google.golang.org/grpc/reflection"
"net"
"os"
"os/signal"
"syscall"
@ -23,42 +22,42 @@ import (
// InterceptorLogger adapts zap logger to interceptor logger.
// This code is simple enough to be copied and not imported.
func InterceptorLogger(l *zap.Logger) logging.Logger {
return logging.LoggerFunc(func(ctx context.Context, lvl logging.Level, msg string, fields ...any) {
f := make([]zap.Field, 0, len(fields)/2)
for i := 0; i < len(fields); i += 2 {
key := fields[i]
value := fields[i+1]
switch v := value.(type) {
case string:
f = append(f, zap.String(key.(string), v))
case int:
f = append(f, zap.Int(key.(string), v))
case bool:
f = append(f, zap.Bool(key.(string), v))
default:
f = append(f, zap.Any(key.(string), v))
}
}
logger := l.WithOptions(zap.AddCallerSkip(1)).With(f...)
switch lvl {
case logging.LevelDebug:
logger.Debug(msg)
case logging.LevelInfo:
logger.Info(msg)
case logging.LevelWarn:
logger.Warn(msg)
case logging.LevelError:
logger.Error(msg)
default:
panic(fmt.Sprintf("unknown level %v", lvl))
}
})
}
//func InterceptorLogger(l *zap.Logger) fiber.Handler {
// return func(ctx context.Context, lvl logging.Level, msg string, fields ...any) {
// f := make([]zap.Field, 0, len(fields)/2)
//
// for i := 0; i < len(fields); i += 2 {
// key := fields[i]
// value := fields[i+1]
//
// switch v := value.(type) {
// case string:
// f = append(f, zap.String(key.(string), v))
// case int:
// f = append(f, zap.Int(key.(string), v))
// case bool:
// f = append(f, zap.Bool(key.(string), v))
// default:
// f = append(f, zap.Any(key.(string), v))
// }
// }
//
// logger := l.WithOptions(zap.AddCallerSkip(1)).With(f...)
//
// switch lvl {
// case logging.LevelDebug:
// logger.Debug(msg)
// case logging.LevelInfo:
// logger.Info(msg)
// case logging.LevelWarn:
// logger.Warn(msg)
// case logging.LevelError:
// logger.Error(msg)
// default:
// panic(fmt.Sprintf("unknown level %v", lvl))
// }
// })
//}
func main() {
var cfg config.Config
@ -101,22 +100,25 @@ func main() {
sessionRepo := usersRepository.NewValkeyRepository(vk)
userUC := usersUseCase.NewUseCase(userRepo, sessionRepo, cfg)
gserver := grpc.NewServer(grpc.ChainUnaryInterceptor(
logging.UnaryServerInterceptor(InterceptorLogger(logger)),
))
defer gserver.GracefulStop()
server := fiber.New()
usersDelivery.NewUserHandlers(gserver, userUC)
reflection.Register(gserver)
ln, err := net.Listen("tcp", cfg.Address)
if err != nil {
panic(err)
}
userv1.RegisterHandlersWithOptions(server, rest.NewUserHandlers(userUC, cfg.JWTSecret), userv1.FiberServerOptions{
Middlewares: []userv1.MiddlewareFunc{
fiberlogger.New(),
rest.AuthMiddleware(cfg.JWTSecret, userUC),
//cors.New(cors.Config{
// AllowOrigins: "http://localhost:3000",
// AllowMethods: "GET,POST,PUT,DELETE,OPTIONS",
// AllowHeaders: "Content-Type,Set-Cookie,Credentials",
// AllowCredentials: true,
//}),
},
})
go func() {
if err = gserver.Serve(ln); err != nil {
panic(err)
err := server.Listen(cfg.Address)
if err != nil {
logger.Fatal(fmt.Sprintf("error starting server: %s", err.Error()))
}
}()

5
config.yaml Normal file
View file

@ -0,0 +1,5 @@
package: userv1
generate:
fiber-server: true
models: true
output: ./proto/user/v1/user.go

58
go.mod
View file

@ -4,42 +4,76 @@ go 1.23.2
require (
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/gofiber/fiber/v2 v2.52.6
github.com/golang-jwt/jwt/v4 v4.5.1
github.com/google/uuid v1.6.0
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.2.0
github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0
github.com/ilyakaznacheev/cleanenv v1.5.0
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438
github.com/oapi-codegen/runtime v1.1.1
github.com/open-policy-agent/opa v1.1.0
github.com/stretchr/testify v1.10.0
github.com/valkey-io/valkey-go v1.0.47
github.com/valkey-io/valkey-go/mock v1.0.47
go.uber.org/mock v0.4.0
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.31.0
google.golang.org/genproto/googleapis/api v0.0.0-20241118233622-e639e219e697
google.golang.org/grpc v1.68.0
google.golang.org/protobuf v1.35.2
golang.org/x/crypto v0.32.0
google.golang.org/grpc v1.70.0
google.golang.org/protobuf v1.36.3
)
require (
github.com/OneOfOne/xxhash v1.2.8 // indirect
github.com/agnivade/levenshtein v1.2.0 // indirect
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-chi/chi/v5 v5.2.1 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/gorilla/mux v1.8.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.12.0 // indirect
github.com/rs/cors v1.11.1 // indirect
github.com/prometheus/client_golang v1.20.5 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.55.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/rcrowley/go-metrics v0.0.0-20200313005456-10cdbea86bc0 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/tchap/go-patricia/v2 v2.3.2 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/yashtewari/glob-intersection v0.2.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel v1.34.0 // indirect
go.opentelemetry.io/otel/metric v1.34.0 // indirect
go.opentelemetry.io/otel/sdk v1.34.0 // indirect
go.opentelemetry.io/otel/trace v1.34.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/net v0.29.0 // indirect
golang.org/x/net v0.34.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.21.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20241118233622-e639e219e697 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f // indirect
sigs.k8s.io/yaml v1.4.0 // indirect
)
require (
github.com/BurntSushi/toml v1.2.1 // indirect
github.com/BurntSushi/toml v1.3.2 // indirect
github.com/jackc/pgx/v5 v5.6.0
github.com/jmoiron/sqlx v1.4.0
github.com/joho/godotenv v1.5.1 // indirect

175
go.sum
View file

@ -1,28 +1,79 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak=
github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8=
github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/agnivade/levenshtein v1.2.0 h1:U9L4IOT0Y3i0TIlUIDJ7rVUziKi/zPbrJGaFrtYH3SY=
github.com/agnivade/levenshtein v1.2.0/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/bytecodealliance/wasmtime-go/v3 v3.0.2 h1:3uZCA/BLTIu+DqCfguByNMJa2HVHpXvjfy0Dy7g6fuA=
github.com/bytecodealliance/wasmtime-go/v3 v3.0.2/go.mod h1:RnUjnIXxEJcL6BgCvNyzCCRzZcxCgsZCi+RNlvYor5Q=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/dgraph-io/badger/v4 v4.5.1 h1:7DCIXrQjo1LKmM96YD+hLVJ2EEsyyoWxJfpdd56HLps=
github.com/dgraph-io/badger/v4 v4.5.1/go.mod h1:qn3Be0j3TfV4kPbVoK0arXCD1/nr1ftth6sbL5jxdoA=
github.com/dgraph-io/ristretto/v2 v2.1.0 h1:59LjpOJLNDULHh8MC4UaegN52lC4JnO2dITsie/Pa8I=
github.com/dgraph-io/ristretto/v2 v2.1.0/go.mod h1:uejeqfYXpUomfse0+lO+13ATz4TypQYLJZzBSAemuB4=
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo=
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
github.com/foxcpp/go-mockdns v1.1.0 h1:jI0rD8M0wuYAxL7r/ynTrCQQq0BVqfB99Vgk7DlmewI=
github.com/foxcpp/go-mockdns v1.1.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27XVI=
github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo=
github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/flatbuffers v24.12.23+incompatible h1:ubBKR94NR4pXUCY/MUsRVzd9umNW7ht7EG9hHfS9FX8=
github.com/google/flatbuffers v24.12.23+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
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/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=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.2.0 h1:kQ0NI7W1B3HwiN5gAYtY+XFItDPbLBwYRxAqbFTyDes=
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.2.0/go.mod h1:zrT2dxOAjNFPRGjTUe2Xmb4q4YdUwVvQFV6xiCSf+z0=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 h1:TmHmbvxPmaegwhDubVz0lICL0J5Ka2vwTzhoePEXsGE=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0/go.mod h1:qztMSjm835F2bXf+5HKAPIS5qsmQDqZna/PgVt4rWtI=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3ArSgIyScOAyMRqBxRg=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ=
github.com/ilyakaznacheev/cleanenv v1.5.0 h1:0VNZXggJE2OYdXE87bfSSwGxeiGt9moSR2lOrsHHvr4=
github.com/ilyakaznacheev/cleanenv v1.5.0/go.mod h1:a5aDzaJrLCQZsazHol1w8InnDcOX0OColm64SlIi6gk=
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0=
@ -39,32 +90,101 @@ github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM=
github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro=
github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/open-policy-agent/opa v1.1.0 h1:HMz2evdEMTyNqtdLjmu3Vyx06BmhNYAx67Yz3Ll9q2s=
github.com/open-policy-agent/opa v1.1.0/go.mod h1:T1pASQ1/vwfTa+e2fYcfpLCvWgYtqtiUv+IuA/dLPQs=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/rcrowley/go-metrics v0.0.0-20200313005456-10cdbea86bc0 h1:MkV+77GLUNo5oJ0jf870itWm3D0Sjh7+Za9gazKc5LQ=
github.com/rcrowley/go-metrics v0.0.0-20200313005456-10cdbea86bc0/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tchap/go-patricia/v2 v2.3.2 h1:xTHFutuitO2zqKAQ5rCROYgUb7Or/+IC3fts9/Yc7nM=
github.com/tchap/go-patricia/v2 v2.3.2/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k=
github.com/valkey-io/valkey-go v1.0.47 h1:fW5+m2BaLAbxB1EWEEWmj+i2n+YcYFBDG/jKs6qu5j8=
github.com/valkey-io/valkey-go v1.0.47/go.mod h1:BXlVAPIL9rFQinSFM+N32JfWzfCaUAqBpZkc4vPY6fM=
github.com/valkey-io/valkey-go/mock v1.0.47 h1:fQZUJrJEx4IG7vH1CjSSqPmx+5Gd6cwwdr7gcDDAIe0=
github.com/valkey-io/valkey-go/mock v1.0.47/go.mod h1:k+lHD29cYer1FO+/HyxWLDUU1JakG6uQ/VgR1OGqe+I=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/yashtewari/glob-intersection v0.2.0 h1:8iuHdN88yYuCzCdjt0gDe+6bAhUwBeEWqThExu54RFg=
github.com/yashtewari/glob-intersection v0.2.0/go.mod h1:LK7pIC3piUjovexikBbJ26Yml7g8xa5bsjfx2v1fwok=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I=
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 h1:tgJ0uaNS4c98WRNUEx5U3aDlrDOI5Rs+1Vifcw4DJ8U=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0/go.mod h1:U7HYyW0zt/a9x5J1Kjs+r1f/d4ZHnYFclhYY2+YbeoE=
go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ=
go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A=
go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU=
go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU=
go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ=
go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4=
go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
@ -73,26 +193,33 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=
golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
google.golang.org/genproto/googleapis/api v0.0.0-20241118233622-e639e219e697 h1:pgr/4QbFyktUv9CtQ/Fq4gzEE6/Xs7iCXbktaGzLHbQ=
google.golang.org/genproto/googleapis/api v0.0.0-20241118233622-e639e219e697/go.mod h1:+D9ySVjN8nY8YCVjc5O7PZDIdZporIDY3KaGfJunh88=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241118233622-e639e219e697 h1:LWZqQOEjDyONlF1H6afSWpAL/znlREo2tHfLoe+8LMA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241118233622-e639e219e697/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU=
google.golang.org/grpc v1.68.0 h1:aHQeeJbo8zAkAa3pRzrVjZlbz6uSfeOXlJNQM0RAbz0=
google.golang.org/grpc v1.68.0/go.mod h1:fmSPC5AsjSBCK54MyHRx48kpOti1/jRfOlwEWywNjWA=
google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io=
google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f h1:gap6+3Gk41EItBuyi4XX/bp4oqJ3UwuIMl25yGinuAA=
google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:Ic02D47M+zbarjYYUlK57y316f2MoN0gjAwI3f2S95o=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f h1:OxYkA3wjPsZyBylwymxSHa7ViiW1Sml4ToBrncvFehI=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50=
google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ=
google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw=
google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU=
google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@ -101,3 +228,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 h1:slmdOY3vp8a7KQbHkL+FLbvbkgMqmXojpFUO/jENuqQ=
olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3/go.mod h1:oVgVk4OWVDi43qWBEyGhXgYxt7+ED4iYNpTngSLX2Iw=
sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=

View file

@ -1,19 +1,19 @@
package models
import (
"encoding/json"
"errors"
"github.com/google/uuid"
"time"
)
type Session struct {
Id string `json:"id" db:"id"`
UserId int32 `json:"user_id" db:"user_id"`
Role Role `json:"role" db:"role"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
//UserAgent string `json:"user_agent"`
//Ip string `json:"ip"`
Id string `json:"id"`
UserId int32 `json:"user_id"`
Role Role `json:"role"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
UserAgent string `json:"user_agent"`
Ip string `json:"ip"`
}
func (s Session) Valid() error {
@ -26,40 +26,46 @@ func (s Session) Valid() error {
if s.CreatedAt.IsZero() {
return errors.New("empty created at")
}
if !s.Role.IsAdmin() && !s.Role.IsModerator() && !s.Role.IsParticipant() {
return errors.New("invalid role")
if s.ExpiresAt.IsZero() {
return errors.New("empty expires at")
}
//if s.UserAgent == "" {
// return errors.New("empty user agent")
//}
//if s.Ip == "" {
// return errors.New("empty ip")
//}
return nil
}
func NewSession(userId int32, role Role) (string, string, error) {
s := &Session{
Id: uuid.NewString(),
UserId: userId,
Role: role,
CreatedAt: time.Now(),
}
if err := s.Valid(); err != nil {
return "", "", err
type JWT struct {
SessionId string `json:"session_id"`
UserId int32 `json:"user_id"`
Role Role `json:"role"`
ExpiresAt int64 `json:"exp"`
IssuedAt int64 `json:"iat"`
NotBefore int64 `json:"nbf"`
Permissions []grant `json:"permissions"`
}
b, err := json.Marshal(s)
if err != nil {
return "", "", err
func (j JWT) Valid() error {
if uuid.Validate(j.SessionId) != nil {
return errors.New("invalid session id")
}
return string(b), s.Id, nil
if j.UserId == 0 {
return errors.New("empty user id")
}
func ParseSession(s string) (*Session, error) {
sess := &Session{}
if err := json.Unmarshal([]byte(s), sess); err != nil {
return nil, err
if j.ExpiresAt == 0 {
return errors.New("empty expires at")
}
if err := sess.Valid(); err != nil {
return nil, err
if j.IssuedAt == 0 {
return errors.New("empty issued at")
}
return sess, nil
if j.NotBefore == 0 {
return errors.New("empty not before")
}
if len(j.Permissions) == 0 {
return errors.New("empty permissions")
}
return nil
}

View file

@ -1,7 +1,9 @@
package models
import (
"errors"
"context"
"encoding/json"
"github.com/open-policy-agent/opa/v1/rego"
"golang.org/x/crypto/bcrypt"
"time"
)
@ -9,30 +11,90 @@ import (
type Role int32
const (
RoleParticipant Role = 0
RoleModerator Role = 1
RoleGuest Role = -1
RoleStudent Role = 0
RoleTeacher Role = 1
RoleAdmin Role = 2
)
func (role Role) IsAdmin() bool {
return role == RoleAdmin
func (r Role) String() string {
switch r {
case RoleGuest:
return "guest"
case RoleStudent:
return "student"
case RoleTeacher:
return "teacher"
case RoleAdmin:
return "admin"
}
func (role Role) IsModerator() bool {
return role == RoleModerator
panic("invalid role")
}
func (role Role) IsParticipant() bool {
return role == RoleParticipant
type Action string
const (
Create Action = "create"
Read Action = "read"
Update Action = "update"
Delete Action = "delete"
)
type Resource string
const (
ResourceAnotherUser Resource = "another-user"
ResourceMeUser Resource = "me-user"
ResourceListUser Resource = "list-user"
ResourceOwnSession Resource = "own-session"
)
type grant struct {
Action Action `json:"action"`
Resource Resource `json:"resource"`
}
func (role Role) AtLeast(other Role) bool {
return role >= other
var Grants = map[string][]grant{
RoleGuest.String(): {},
RoleStudent.String(): {
{Read, ResourceAnotherUser},
{Read, ResourceMeUser},
{Update, ResourceOwnSession},
{Delete, ResourceOwnSession},
},
RoleTeacher.String(): {
{Create, ResourceAnotherUser},
{Read, ResourceAnotherUser},
{Read, ResourceMeUser},
{Read, ResourceListUser},
{Update, ResourceOwnSession},
{Delete, ResourceOwnSession},
},
RoleAdmin.String(): {
{Create, ResourceAnotherUser},
{Read, ResourceAnotherUser},
{Read, ResourceMeUser},
{Read, ResourceListUser},
{Update, ResourceAnotherUser},
{Update, ResourceOwnSession},
{Delete, ResourceAnotherUser},
{Delete, ResourceOwnSession},
},
}
func (role Role) AtMost(other Role) bool {
return role <= other
const module = `package app.rbac
default allow := false
allow if {
some grant in input.role_grants[input.role]
input.action == grant.action
input.resource == grant.resource
}
`
type User struct {
Id int32 `db:"id"`
@ -43,10 +105,61 @@ type User struct {
Role Role `db:"role"`
}
func (user *User) ComparePassword(password string) error {
func (user *User) MarshalJSON() ([]byte, error) {
m := map[string]interface{}{
"id": user.Id,
"username": user.Username,
"created_at": user.CreatedAt,
"modified_at": user.ModifiedAt,
"role": user.Role,
}
b, err := json.Marshal(m)
if err != nil {
return nil, err
}
return b, nil
}
func (user *User) IsSamePwd(password string) bool {
err := bcrypt.CompareHashAndPassword([]byte(user.HashedPassword), []byte(password))
if err != nil {
return errors.New("bad username or password")
return false
}
return true
}
var query rego.PreparedEvalQuery
func (r Role) HasPermission(action Action, resource Resource) bool {
ctx := context.TODO()
input := map[string]interface{}{
"action": action,
"resource": resource,
"role": r.String(),
"role_grants": Grants,
}
results, err := query.Eval(ctx, rego.EvalInput(input))
if err != nil {
panic(err)
}
return results.Allowed()
}
func init() {
var err error
ctx := context.TODO()
query, err = rego.New(
rego.Query("data.app.rbac.allow"),
rego.Module("ms-auth.rego", module),
).PrepareForEval(ctx)
if err != nil {
panic(err)
}
return nil
}

View file

@ -1,19 +1,21 @@
package users
import (
"context"
userv1 "git.sch9.ru/new_gate/ms-auth/proto/user/v1"
"google.golang.org/protobuf/types/known/emptypb"
"github.com/gofiber/fiber/v2"
)
type UserHandlers interface {
CreateUser(ctx context.Context, req *userv1.CreateUserRequest) (*userv1.CreateUserResponse, error)
GetUser(ctx context.Context, req *userv1.GetUserRequest) (*userv1.GetUserResponse, error)
UpdateUser(ctx context.Context, req *userv1.UpdateUserRequest) (*emptypb.Empty, error)
DeleteUser(ctx context.Context, req *userv1.DeleteUserRequest) (*emptypb.Empty, error)
Login(ctx context.Context, req *userv1.LoginRequest) (*emptypb.Empty, error)
Verify(ctx context.Context, req *emptypb.Empty) (*emptypb.Empty, error)
Refresh(ctx context.Context, req *emptypb.Empty) (*emptypb.Empty, error)
Logout(ctx context.Context, req *emptypb.Empty) (*emptypb.Empty, error)
CompleteLogout(ctx context.Context, req *emptypb.Empty) (*emptypb.Empty, error)
ListSessions(c *fiber.Ctx) error
CompleteLogout(c *fiber.Ctx) error
Login(c *fiber.Ctx) error
Logout(c *fiber.Ctx) error
Refresh(c *fiber.Ctx) error
Verify(c *fiber.Ctx) error
ListUsers(c *fiber.Ctx, params userv1.ListUsersParams) error
CreateUser(c *fiber.Ctx) error
GetMe(c *fiber.Ctx) error
DeleteUser(c *fiber.Ctx, id int32) error
GetUser(c *fiber.Ctx, id int32) error
UpdateUser(c *fiber.Ctx, id int32) error
}

View file

@ -1,283 +0,0 @@
package grpc
import (
"context"
"errors"
"git.sch9.ru/new_gate/ms-auth/internal/models"
"git.sch9.ru/new_gate/ms-auth/internal/users"
"git.sch9.ru/new_gate/ms-auth/pkg"
userv1 "git.sch9.ru/new_gate/ms-auth/proto/user/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
"google.golang.org/protobuf/types/known/emptypb"
"google.golang.org/protobuf/types/known/timestamppb"
"strings"
)
type UserHandlers struct {
userv1.UnimplementedUserServiceServer
userUC users.UseCase
}
func NewUserHandlers(gserver *grpc.Server, userUC users.UseCase) {
handlers := &UserHandlers{
userUC: userUC,
}
userv1.RegisterUserServiceServer(gserver, handlers)
}
const (
SessionHeaderName = "x-session-id"
AuthUserHeaderName = "x-auth-user-id"
)
func (h *UserHandlers) Login(ctx context.Context, req *userv1.LoginRequest) (*emptypb.Empty, error) {
const op = "UserHandlers.Login"
var (
err error
user *models.User
)
username := req.GetUsername()
password := req.GetPassword()
user, err = h.userUC.ReadUserByUsername(ctx, username)
if err != nil {
return nil, pkg.ToGRPC(err)
}
err = user.ComparePassword(password)
if err != nil {
return nil, pkg.ToGRPC(pkg.Wrap(pkg.ErrNotFound, err, op, "bad username or password"))
}
sessionId, err := h.userUC.CreateSession(ctx, user.Id, user.Role)
if err != nil {
return nil, pkg.ToGRPC(err)
}
header := metadata.New(map[string]string{
SessionHeaderName: sessionId,
})
err = grpc.SendHeader(ctx, header)
if err != nil {
return nil, err
}
return &emptypb.Empty{}, nil
}
func AuthSessionIdFromContext(ctx context.Context) (string, error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return "", errors.New("failed to get metadata")
}
tokens := md.Get(SessionHeaderName)
sessionId := strings.Join(tokens, "")
if len(sessionId) == 0 {
return "", errors.New("no session id in context")
}
return sessionId, nil
}
func (h *UserHandlers) Refresh(ctx context.Context, _ *emptypb.Empty) (*emptypb.Empty, error) {
const op = "UserHandlers.Refresh"
sessionId, err := AuthSessionIdFromContext(ctx)
if err != nil {
return nil, pkg.ToGRPC(pkg.Wrap(err, pkg.ErrUnauthenticated, op, "no session id in context"))
}
err = h.userUC.UpdateSession(ctx, sessionId)
if err != nil {
return nil, pkg.ToGRPC(err)
}
return &emptypb.Empty{}, nil
}
func (h *UserHandlers) Logout(ctx context.Context, _ *emptypb.Empty) (*emptypb.Empty, error) {
const op = "UserHandlers.Logout"
sessionId, err := AuthSessionIdFromContext(ctx)
if err != nil {
return nil, pkg.ToGRPC(pkg.Wrap(err, pkg.ErrUnauthenticated, op, "no session id in context"))
}
err = h.userUC.DeleteSession(ctx, sessionId)
if err != nil {
return nil, pkg.ToGRPC(err)
}
header := metadata.New(map[string]string{
SessionHeaderName: "",
})
err = grpc.SendHeader(ctx, header)
if err != nil {
return nil, err
}
return &emptypb.Empty{}, nil
}
func (h *UserHandlers) CompleteLogout(ctx context.Context, _ *emptypb.Empty) (*emptypb.Empty, error) {
const op = "UserHandlers.CompleteLogout"
sessionId, err := AuthSessionIdFromContext(ctx)
if err != nil {
return nil, pkg.ToGRPC(pkg.Wrap(err, pkg.ErrUnauthenticated, op, "no session id in context"))
}
session, err := h.userUC.ReadSession(ctx, sessionId)
if err != nil {
return nil, pkg.ToGRPC(err)
}
err = h.userUC.DeleteAllSessions(ctx, session.UserId)
if err != nil {
return nil, pkg.ToGRPC(err)
}
return &emptypb.Empty{}, nil
}
func (h *UserHandlers) Verify(ctx context.Context, _ *emptypb.Empty) (*emptypb.Empty, error) {
const op = "UserHandlers.Verify"
sessionId, err := AuthSessionIdFromContext(ctx)
if err != nil {
return nil, pkg.ToGRPC(pkg.Wrap(err, pkg.ErrUnauthenticated, op, "no session id in context"))
}
token, err := h.userUC.Verify(ctx, sessionId)
if err != nil {
return nil, pkg.ToGRPC(err)
}
header := metadata.New(map[string]string{
AuthUserHeaderName: token,
})
err = grpc.SendHeader(ctx, header)
if err != nil {
return nil, err
}
return &emptypb.Empty{}, nil
}
func (h *UserHandlers) CreateUser(ctx context.Context, req *userv1.CreateUserRequest) (*userv1.CreateUserResponse, error) {
const op = "UserHandlers.CreateUser"
sessionId, err := AuthSessionIdFromContext(ctx)
if err != nil {
return nil, pkg.ToGRPC(pkg.Wrap(err, pkg.ErrUnauthenticated, op, "no session id in context"))
}
ctx = context.WithValue(ctx, "userId", sessionId)
id, err := h.userUC.CreateUser(
ctx,
req.GetUsername(),
req.GetPassword(),
models.RoleParticipant,
)
if err != nil {
return nil, pkg.ToGRPC(err)
}
return &userv1.CreateUserResponse{
Id: id,
}, nil
}
func (h *UserHandlers) GetUser(ctx context.Context, req *userv1.GetUserRequest) (*userv1.GetUserResponse, error) {
const op = "UserHandlers.GetUser"
var userId = req.GetId()
if req.GetMe() {
sessionId, err := AuthSessionIdFromContext(ctx)
if err != nil {
return nil, pkg.ToGRPC(pkg.Wrap(err, pkg.ErrUnauthenticated, op, "no session id in context"))
}
session, err := h.userUC.ReadSession(ctx, sessionId)
if err != nil {
return nil, pkg.ToGRPC(err)
}
userId = session.UserId
}
user, err := h.userUC.ReadUserById(
ctx,
userId,
)
if err != nil {
return nil, pkg.ToGRPC(err)
}
return &userv1.GetUserResponse{
User: &userv1.User{
Id: user.Id,
Username: user.Username,
CreatedAt: timestamppb.New(user.CreatedAt),
ModifiedAt: timestamppb.New(user.ModifiedAt),
Role: userv1.Role(user.Role),
},
}, nil
}
func (h *UserHandlers) UpdateUser(ctx context.Context, req *userv1.UpdateUserRequest) (*emptypb.Empty, error) {
const op = "UserHandlers.UpdateUser"
sessionId, err := AuthSessionIdFromContext(ctx)
if err != nil {
return nil, pkg.ToGRPC(pkg.Wrap(err, pkg.ErrUnauthenticated, op, "no session id in context"))
}
ctx = context.WithValue(ctx, "userId", sessionId)
err = h.userUC.UpdateUser(
ctx,
req.GetId(),
AsStringP(req.Username),
AsMRoleP(req.Role),
)
if err != nil {
return nil, pkg.ToGRPC(err)
}
return &emptypb.Empty{}, nil
}
func (h *UserHandlers) DeleteUser(ctx context.Context, req *userv1.DeleteUserRequest) (*emptypb.Empty, error) {
const op = "UserHandlers.DeleteUser"
sessionId, err := AuthSessionIdFromContext(ctx)
if err != nil {
return nil, pkg.ToGRPC(pkg.Wrap(err, pkg.ErrUnauthenticated, op, "no session id in context"))
}
ctx = context.WithValue(ctx, "userId", sessionId)
err = h.userUC.DeleteUser(
ctx,
req.GetId(),
)
if err != nil {
return nil, pkg.ToGRPC(err)
}
return &emptypb.Empty{}, nil
}
func AsMRoleP(v userv1.Role) *models.Role {
vv := models.Role(v.Number())
return &vv
}
func AsRoleP(v models.Role) *models.Role {
return &v
}
func AsStringP(str string) *string {
return &str
}

View file

@ -1,428 +0,0 @@
package grpc
import (
"context"
"git.sch9.ru/new_gate/ms-auth/internal/models"
"git.sch9.ru/new_gate/ms-auth/internal/users"
mock_users "git.sch9.ru/new_gate/ms-auth/internal/users/delivery/mock"
userv1 "git.sch9.ru/new_gate/ms-auth/proto/user/v1"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"golang.org/x/crypto/bcrypt"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/emptypb"
"net"
"testing"
"time"
)
func startServer(t *testing.T, uc users.UseCase, addr string) {
t.Helper()
gserver := grpc.NewServer()
NewUserHandlers(gserver, uc)
ln, err := net.Listen("tcp", addr)
if err != nil {
panic(err)
}
go func() {
if err = gserver.Serve(ln); err != nil {
panic(err)
}
}()
t.Cleanup(func() {
gserver.Stop()
})
}
func buildClient(t *testing.T, addr string) userv1.UserServiceClient {
t.Helper()
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
require.NoError(t, err)
return userv1.NewUserServiceClient(conn)
}
func TestUserHandlers_Login(t *testing.T) {
t.Parallel()
const addr = "127.0.0.1:62999"
ctrl := gomock.NewController(t)
defer ctrl.Finish()
uc := mock_users.NewMockUseCase(ctrl)
startServer(t, uc, addr)
client := buildClient(t, addr)
t.Run("valid login", func(t *testing.T) {
password := "password"
hpwd, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
require.NoError(t, err)
user := &models.User{
Id: 1,
Username: "username",
HashedPassword: string(hpwd),
Role: models.RoleAdmin,
}
sid := uuid.NewString()
uc.EXPECT().ReadUserByUsername(gomock.Any(), user.Username).Return(user, nil)
uc.EXPECT().CreateSession(gomock.Any(), user.Id, user.Role).Return(sid, nil)
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(5*time.Second))
t.Cleanup(cancel)
var header metadata.MD
_, err = client.Login(ctx, &userv1.LoginRequest{
Username: user.Username,
Password: password,
}, grpc.Header(&header))
require.NoError(t, err)
require.Equal(t, sid, header.Get(SessionHeaderName)[0])
})
t.Run("invalid login (wrong password)", func(t *testing.T) {
password := "password"
hpwd, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
require.NoError(t, err)
user := &models.User{
Id: 1,
Username: "username",
HashedPassword: string(hpwd),
Role: models.RoleAdmin,
}
uc.EXPECT().ReadUserByUsername(gomock.Any(), user.Username).Return(user, nil)
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(5*time.Second))
t.Cleanup(cancel)
_, err = client.Login(ctx, &userv1.LoginRequest{
Username: user.Username,
Password: "wrongpassword",
})
s, ok := status.FromError(err)
require.True(t, ok)
require.Equal(t, codes.NotFound, s.Code())
})
}
func TestUserHandlers_Refresh(t *testing.T) {
t.Parallel()
const addr = "127.0.0.1:62998"
ctrl := gomock.NewController(t)
defer ctrl.Finish()
uc := mock_users.NewMockUseCase(ctrl)
startServer(t, uc, addr)
client := buildClient(t, addr)
t.Run("valid refresh", func(t *testing.T) {
sid := uuid.NewString()
uc.EXPECT().UpdateSession(gomock.Any(), sid).Return(nil)
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(5*time.Second))
t.Cleanup(cancel)
ctx = metadata.AppendToOutgoingContext(ctx, SessionHeaderName, sid)
_, err := client.Refresh(ctx, &emptypb.Empty{})
require.NoError(t, err)
})
t.Run("invalid refresh (no session id in context)", func(t *testing.T) {
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(5*time.Second))
t.Cleanup(cancel)
_, err := client.Refresh(ctx, &emptypb.Empty{})
s, ok := status.FromError(err)
require.True(t, ok)
require.Equal(t, codes.Unauthenticated, s.Code())
})
}
func TestUserHandlers_Logout(t *testing.T) {
t.Parallel()
const addr = "127.0.0.1:62997"
ctrl := gomock.NewController(t)
defer ctrl.Finish()
uc := mock_users.NewMockUseCase(ctrl)
startServer(t, uc, addr)
client := buildClient(t, addr)
t.Run("valid logout", func(t *testing.T) {
sid := uuid.NewString()
uc.EXPECT().DeleteSession(gomock.Any(), sid).Return(nil)
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(5*time.Second))
t.Cleanup(cancel)
ctx = metadata.AppendToOutgoingContext(ctx, SessionHeaderName, sid)
_, err := client.Logout(ctx, &emptypb.Empty{})
require.NoError(t, err)
})
t.Run("invalid logout (no session id in context)", func(t *testing.T) {
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(5*time.Second))
t.Cleanup(cancel)
_, err := client.Logout(ctx, &emptypb.Empty{})
s, ok := status.FromError(err)
require.True(t, ok)
require.Equal(t, codes.Unauthenticated, s.Code())
})
}
func TestUserHandlers_CompleteLogout(t *testing.T) {
t.Parallel()
const addr = "127.0.0.1:62996"
ctrl := gomock.NewController(t)
defer ctrl.Finish()
uc := mock_users.NewMockUseCase(ctrl)
startServer(t, uc, addr)
client := buildClient(t, addr)
t.Run("valid complete logout", func(t *testing.T) {
sid := uuid.NewString()
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(5*time.Second))
ctx = metadata.AppendToOutgoingContext(ctx, SessionHeaderName, sid)
t.Cleanup(cancel)
uc.EXPECT().ReadSession(gomock.Any(), sid).Return(&models.Session{UserId: 1}, nil)
uc.EXPECT().DeleteAllSessions(gomock.Any(), int32(1)).Return(nil)
_, err := client.CompleteLogout(ctx, &emptypb.Empty{})
require.NoError(t, err)
})
t.Run("invalid complete logout (no session id in context)", func(t *testing.T) {
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(5*time.Second))
t.Cleanup(cancel)
_, err := client.CompleteLogout(ctx, &emptypb.Empty{})
s, ok := status.FromError(err)
require.True(t, ok)
require.Equal(t, codes.Unauthenticated, s.Code())
})
}
func TestUserHandlers_Verify(t *testing.T) {
t.Parallel()
const addr = "127.0.0.1:62995"
ctrl := gomock.NewController(t)
defer ctrl.Finish()
uc := mock_users.NewMockUseCase(ctrl)
startServer(t, uc, addr)
client := buildClient(t, addr)
t.Run("valid verify", func(t *testing.T) {
sid := uuid.NewString()
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(5*time.Second))
ctx = metadata.AppendToOutgoingContext(ctx, SessionHeaderName, sid)
t.Cleanup(cancel)
uc.EXPECT().Verify(gomock.Any(), sid).Return("jwt", nil)
var header metadata.MD
_, err := client.Verify(ctx, &emptypb.Empty{}, grpc.Header(&header))
require.NoError(t, err)
require.Equal(t, header.Get(AuthUserHeaderName)[0], "jwt")
})
t.Run("invalid verify (no session id in context)", func(t *testing.T) {
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(5*time.Second))
t.Cleanup(cancel)
_, err := client.Verify(ctx, &emptypb.Empty{})
s, ok := status.FromError(err)
require.True(t, ok)
require.Equal(t, codes.Unauthenticated, s.Code())
})
}
func TestUserHandlers_CreateUser(t *testing.T) {
t.Parallel()
const addr = "127.0.0.1:62994"
ctrl := gomock.NewController(t)
defer ctrl.Finish()
uc := mock_users.NewMockUseCase(ctrl)
startServer(t, uc, addr)
client := buildClient(t, addr)
t.Run("valid create user", func(t *testing.T) {
username := "username"
password := "password"
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(5*time.Second))
ctx = metadata.AppendToOutgoingContext(ctx, SessionHeaderName, uuid.NewString())
t.Cleanup(cancel)
uc.EXPECT().CreateUser(gomock.Any(), username, password, models.RoleParticipant).Return(int32(2), nil)
_, err := client.CreateUser(ctx, &userv1.CreateUserRequest{
Username: username,
Password: password,
})
require.NoError(t, err)
})
t.Run("invalid create user (no session id in context)", func(t *testing.T) {
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(5*time.Second))
t.Cleanup(cancel)
_, err := client.CreateUser(ctx, &userv1.CreateUserRequest{
Username: "username",
Password: "password",
})
s, ok := status.FromError(err)
require.True(t, ok)
require.Equal(t, codes.Unauthenticated, s.Code())
})
}
func TestUserHandlers_GetUser(t *testing.T) {
t.Parallel()
const addr = "127.0.0.1:62993"
ctrl := gomock.NewController(t)
defer ctrl.Finish()
uc := mock_users.NewMockUseCase(ctrl)
startServer(t, uc, addr)
client := buildClient(t, addr)
t.Run("valid get user", func(t *testing.T) {
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(5*time.Second))
t.Cleanup(cancel)
uc.EXPECT().ReadUserById(gomock.Any(), int32(1)).Return(&models.User{
Id: 1,
Username: "username",
CreatedAt: time.Now(),
ModifiedAt: time.Now(),
Role: models.RoleParticipant,
}, nil)
_, err := client.GetUser(ctx, &userv1.GetUserRequest{
Id: 1,
})
require.NoError(t, err)
})
}
func TestUserHandlers_UpdateUser(t *testing.T) {
t.Parallel()
const addr = "127.0.0.1:62992"
ctrl := gomock.NewController(t)
defer ctrl.Finish()
uc := mock_users.NewMockUseCase(ctrl)
startServer(t, uc, addr)
client := buildClient(t, addr)
t.Run("valid update user", func(t *testing.T) {
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(5*time.Second))
ctx = metadata.AppendToOutgoingContext(ctx, SessionHeaderName, uuid.NewString())
t.Cleanup(cancel)
uc.EXPECT().UpdateUser(gomock.Any(),
int32(1),
AsStringP("username"),
AsRoleP(models.RoleModerator),
).Return(nil)
_, err := client.UpdateUser(ctx, &userv1.UpdateUserRequest{
Id: 1,
Username: "username",
Role: userv1.Role_ROLE_MODERATOR,
})
require.NoError(t, err)
})
t.Run("invalid update user (no session id in context)", func(t *testing.T) {
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(5*time.Second))
t.Cleanup(cancel)
_, err := client.UpdateUser(ctx, &userv1.UpdateUserRequest{
Id: 1,
Username: "username",
Role: userv1.Role_ROLE_MODERATOR,
})
s, ok := status.FromError(err)
require.True(t, ok)
require.Equal(t, codes.Unauthenticated, s.Code())
})
}
func TestUserHandlers_DeleteUser(t *testing.T) {
t.Parallel()
const addr = "127.0.0.1:62991"
ctrl := gomock.NewController(t)
defer ctrl.Finish()
uc := mock_users.NewMockUseCase(ctrl)
startServer(t, uc, addr)
client := buildClient(t, addr)
t.Run("valid delete user", func(t *testing.T) {
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(5*time.Second))
ctx = metadata.AppendToOutgoingContext(ctx, SessionHeaderName, uuid.NewString())
t.Cleanup(cancel)
uc.EXPECT().DeleteUser(gomock.Any(), int32(1)).Return(nil)
_, err := client.DeleteUser(ctx, &userv1.DeleteUserRequest{
Id: 1,
})
require.NoError(t, err)
})
}

View file

@ -0,0 +1,265 @@
package rest
import (
"encoding/base64"
"errors"
"git.sch9.ru/new_gate/ms-auth/internal/models"
"git.sch9.ru/new_gate/ms-auth/internal/users"
"git.sch9.ru/new_gate/ms-auth/pkg"
userv1 "git.sch9.ru/new_gate/ms-auth/proto/user/v1"
"github.com/gofiber/fiber/v2"
"github.com/golang-jwt/jwt/v4"
"strings"
"time"
)
type UserHandlers struct {
userUC users.UseCase
jwtSecret string
}
func NewUserHandlers(userUC users.UseCase, jwtSecret string) *UserHandlers {
return &UserHandlers{
userUC: userUC,
jwtSecret: jwtSecret,
}
}
func (h *UserHandlers) Login(c *fiber.Ctx) error {
const op = "UserHandlers.Login"
authHeader := c.Get("Authorization", "")
if authHeader == "" {
return c.SendStatus(fiber.StatusUnauthorized)
}
authParts := strings.Split(authHeader, " ")
if len(authParts) != 2 || strings.ToLower(authParts[0]) != "basic" {
return c.SendStatus(fiber.StatusUnauthorized)
}
decodedAuth, err := base64.StdEncoding.DecodeString(authParts[1])
if err != nil {
return c.SendStatus(fiber.StatusUnauthorized)
}
authParts = strings.Split(string(decodedAuth), ":")
if len(authParts) != 2 {
return c.SendStatus(fiber.StatusUnauthorized)
}
ctx := c.Context()
user, err := h.userUC.ReadUserByUsername(ctx, authParts[0])
if err != nil {
if errors.Is(err, pkg.ErrNotFound) {
return c.SendStatus(fiber.StatusUnauthorized)
}
return c.SendStatus(pkg.ToREST(err))
}
if !user.IsSamePwd(authParts[1]) {
return c.SendStatus(fiber.StatusUnauthorized)
}
userAgent := c.Get("User-Agent", "")
ip := c.IP()
session, err := h.userUC.CreateSession(ctx, user.Id, user.Role, userAgent, ip)
if err != nil {
return c.SendStatus(pkg.ToREST(err))
}
claims := jwt.NewWithClaims(jwt.SigningMethodHS256, models.JWT{
SessionId: session.Id,
UserId: user.Id,
Role: user.Role,
ExpiresAt: session.ExpiresAt.Unix(),
IssuedAt: time.Now().Unix(),
NotBefore: time.Now().Unix(),
Permissions: models.Grants[user.Role.String()],
})
token, err := claims.SignedString([]byte(h.jwtSecret))
if err != nil {
return c.SendStatus(fiber.StatusInternalServerError)
}
c.Set("Authorization", "Bearer "+token)
return c.SendStatus(fiber.StatusOK)
}
func (h *UserHandlers) Refresh(c *fiber.Ctx) error {
const op = "UserHandlers.Refresh"
token, ok := c.Locals(TokenKey).(*models.JWT)
if !ok {
return c.SendStatus(fiber.StatusUnauthorized)
}
err := h.userUC.UpdateSession(c.Context(), token.SessionId)
if err != nil {
return c.SendStatus(pkg.ToREST(err))
}
return c.SendStatus(fiber.StatusOK)
}
func (h *UserHandlers) Logout(c *fiber.Ctx) error {
const op = "UserHandlers.Logout"
token, ok := c.Locals(TokenKey).(*models.JWT)
if !ok {
return c.SendStatus(fiber.StatusUnauthorized)
}
err := h.userUC.DeleteSession(c.Context(), token.SessionId)
if err != nil {
return c.SendStatus(pkg.ToREST(err))
}
return c.SendStatus(fiber.StatusOK)
}
func (h *UserHandlers) CompleteLogout(c *fiber.Ctx) error {
const op = "UserHandlers.CompleteLogout"
token, ok := c.Locals(TokenKey).(*models.JWT)
if !ok {
return c.SendStatus(fiber.StatusUnauthorized)
}
ctx := c.Context()
err := h.userUC.DeleteAllSessions(ctx, token.UserId)
if err != nil {
return c.SendStatus(pkg.ToREST(err))
}
return c.SendStatus(fiber.StatusOK)
}
func (h *UserHandlers) Verify(c *fiber.Ctx) error {
const op = "UserHandlers.Verify"
return c.SendStatus(fiber.StatusNotImplemented)
}
func (h *UserHandlers) CreateUser(c *fiber.Ctx) error {
const op = "UserHandlers.CreateUser"
ctx := c.Context()
id, err := h.userUC.CreateUser(
ctx,
c.FormValue("username"),
c.FormValue("password"),
models.RoleStudent,
)
if err != nil {
return c.SendStatus(pkg.ToREST(err))
}
return c.JSON(map[string]interface{}{
"id": id,
})
}
func (h *UserHandlers) GetMe(c *fiber.Ctx) error {
const op = "UserHandlers.GetMe"
token, ok := c.Locals(TokenKey).(*models.JWT)
if !ok {
return c.SendStatus(fiber.StatusUnauthorized)
}
user, err := h.userUC.ReadUserById(c.Context(), token.UserId)
if err != nil {
return c.SendStatus(pkg.ToREST(err))
}
return c.JSON(map[string]interface{}{
"user": user,
})
}
func (h *UserHandlers) GetUser(c *fiber.Ctx, id int32) error {
const op = "UserHandlers.GetUser"
user, err := h.userUC.ReadUserById(c.Context(), id)
if err != nil {
return c.SendStatus(pkg.ToREST(err))
}
return c.JSON(map[string]interface{}{
"user": user,
})
}
func (h *UserHandlers) UpdateUser(c *fiber.Ctx, id int32) error {
const op = "UserHandlers.UpdateUser"
var req = &userv1.UpdateUserRequest{}
err := c.BodyParser(req)
if err != nil {
return c.SendStatus(fiber.StatusBadRequest)
}
err = h.userUC.UpdateUser(c.Context(), id, req.Username, int32PtrToRolePtr(req.Role))
if err != nil {
return c.SendStatus(pkg.ToREST(err))
}
return c.SendStatus(fiber.StatusOK)
}
func (h *UserHandlers) DeleteUser(c *fiber.Ctx, id int32) error {
const op = "UserHandlers.DeleteUser"
ctx := c.Context()
err := h.userUC.DeleteUser(ctx, id)
if err != nil {
return c.SendStatus(pkg.ToREST(err))
}
return c.SendStatus(fiber.StatusOK)
}
func (h *UserHandlers) ListUsers(c *fiber.Ctx, params userv1.ListUsersParams) error {
const op = "UserHandlers.ListUsers"
usersList, count, err := h.userUC.ListUsers(c.Context(), params.Page, params.PageSize)
if err != nil {
return c.SendStatus(pkg.ToREST(err))
}
return c.JSON(map[string]interface{}{
"users": usersList,
"page": params.Page,
"max_page": func() int32 {
if count%params.PageSize == 0 {
return count / params.PageSize
}
return count/params.PageSize + 1
}(),
})
}
func (h *UserHandlers) ListSessions(c *fiber.Ctx) error {
const op = "UserHandlers.ListSessions"
return c.SendStatus(fiber.StatusNotImplemented)
}
func int32PtrToRolePtr(i *int32) *models.Role {
if i == nil {
return nil
}
ii := models.Role(*i)
return &ii
}

View file

@ -0,0 +1,74 @@
package rest
import (
"errors"
"fmt"
"git.sch9.ru/new_gate/ms-auth/internal/models"
"git.sch9.ru/new_gate/ms-auth/internal/users"
"git.sch9.ru/new_gate/ms-auth/pkg"
"github.com/gofiber/fiber/v2"
"github.com/golang-jwt/jwt/v4"
"strings"
)
const (
TokenKey = "token"
)
func AuthMiddleware(jwtSecret string, userUC users.UseCase) fiber.Handler {
return func(c *fiber.Ctx) error {
const op = "AuthMiddleware"
authHeader := c.Get("Authorization", "")
if authHeader == "" {
c.Locals(TokenKey, nil)
return c.Next()
}
authParts := strings.Split(authHeader, " ")
if len(authParts) != 2 || strings.ToLower(authParts[0]) != "bearer" {
c.Locals(TokenKey, nil)
return c.Next()
}
parsedToken, err := jwt.ParseWithClaims(authParts[1], &models.JWT{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(jwtSecret), nil
})
if err != nil {
c.Locals(TokenKey, nil)
return c.Next()
}
token, ok := parsedToken.Claims.(*models.JWT)
if !ok {
c.Locals(TokenKey, nil)
return c.Next()
}
err = token.Valid()
if err != nil {
c.Locals(TokenKey, nil)
return c.Next()
}
ctx := c.Context()
// check if session exists
_, err = userUC.ReadSession(ctx, token.SessionId)
if err != nil {
if errors.Is(err, pkg.ErrNotFound) {
c.Locals(TokenKey, nil)
return c.Next()
}
return c.SendStatus(pkg.ToREST(err))
}
c.Locals(TokenKey, token)
return c.Next()
}
}

View file

@ -11,6 +11,7 @@ type Caller interface {
ReadUserById(ctx context.Context, id int32) (*models.User, error)
UpdateUser(ctx context.Context, id int32, username *string, role *models.Role) error
DeleteUser(ctx context.Context, id int32) error
ListUsers(ctx context.Context, page int32, pageSize int32) ([]*models.User, int32, error)
}
type TxCaller interface {
@ -25,7 +26,7 @@ type PgRepository interface {
}
type ValkeyRepository interface {
CreateSession(ctx context.Context, userId int32, role models.Role) (string, error)
CreateSession(ctx context.Context, userId int32, role models.Role, userAgent, ip string) (*models.Session, error)
ReadSession(ctx context.Context, sessionId string) (*models.Session, error)
UpdateSession(ctx context.Context, sessionId string) error
DeleteSession(ctx context.Context, sessionId string) error

View file

@ -47,6 +47,7 @@ type TxOrDB interface {
GetContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error
QueryxContext(ctx context.Context, query string, args ...interface{}) (*sqlx.Rows, error)
ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error)
SelectContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error
}
type Caller struct {
@ -202,6 +203,36 @@ func (c *Caller) DeleteUser(ctx context.Context, id int32) error {
return nil
}
const (
ListUsers = "SELECT * FROM users LIMIT ? OFFSET ?"
CountUsers = "SELECT COUNT(*) FROM users"
)
func (c *Caller) ListUsers(ctx context.Context, page int32, pageSize int32) ([]*models.User, int32, error) {
const op = "Caller.ListUsers"
if pageSize > 20 {
return nil, 0, pkg.Wrap(pkg.ErrBadInput, nil, op, "limit > 20")
}
var usersList []*models.User
query := c.db.Rebind(ListUsers)
err := c.db.SelectContext(ctx, &usersList, query, pageSize, (page-1)*pageSize)
if err != nil {
return nil, 0, handlePgErr(err, op)
}
query = c.db.Rebind(CountUsers)
var count int32
err = c.db.GetContext(ctx, &count, query)
if err != nil {
return nil, 0, handlePgErr(err, op)
}
return usersList, count, nil
}
func handlePgErr(err error, op string) error {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
@ -255,9 +286,9 @@ func ValidRole(role models.Role) error {
switch role {
case models.RoleAdmin:
return nil
case models.RoleModerator:
case models.RoleTeacher:
return nil
case models.RoleParticipant:
case models.RoleStudent:
return nil
}
return errors.New("invalid role")

View file

@ -2,6 +2,9 @@ package repository
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"git.sch9.ru/new_gate/ms-auth/internal/models"
"git.sch9.ru/new_gate/ms-auth/pkg"
@ -23,31 +26,55 @@ func NewValkeyRepository(db valkey.Client) *ValkeyRepository {
const sessionLifetime = time.Minute * 40
func (r *ValkeyRepository) CreateSession(ctx context.Context, userId int32, role models.Role) (string, error) {
func sha256string(s string) string {
hasher := sha256.New()
hasher.Write([]byte(s))
return hex.EncodeToString(hasher.Sum(nil))
}
func (r *ValkeyRepository) CreateSession(ctx context.Context, userId int32, role models.Role, userAgent, ip string) (*models.Session, error) {
const op = "ValkeyRepository.CreateSession"
sessionData, sessionId, err := models.NewSession(userId, role)
session := &models.Session{
Id: uuid.NewString(),
UserId: userId,
Role: role,
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(sessionLifetime),
UserAgent: userAgent,
Ip: ip,
}
err := session.Valid()
if err != nil {
return "", pkg.Wrap(pkg.ErrBadInput, err, op, "building session")
return nil, pkg.Wrap(pkg.ErrInternal, err, op, "validating session")
}
userIdHash := sha256string(strconv.FormatInt(int64(userId), 10))
sessionIdHash := sha256string(session.Id)
sessionData, err := json.Marshal(session)
if err != nil {
return nil, pkg.Wrap(pkg.ErrInternal, err, op, "marshaling session")
}
resp := r.db.Do(ctx, r.db.
B().Set().
Key(fmt.Sprintf("userid:%d:sessionid:%s", userId, sessionId)).
Value(sessionData).
Ex(sessionLifetime).
Key(fmt.Sprintf("userid:%s:sessionid:%s", userIdHash, sessionIdHash)).
Value(string(sessionData)).
Exat(session.ExpiresAt).
Build(),
)
err = resp.Error()
if err != nil {
if valkey.IsValkeyNil(err) {
return "", pkg.Wrap(pkg.ErrBadInput, err, op, "nil response")
return nil, pkg.Wrap(pkg.ErrInternal, err, op, "nil response")
}
return "", pkg.Wrap(pkg.ErrUnhandled, err, op, "unhandled valkey error")
return nil, pkg.Wrap(pkg.ErrUnhandled, err, op, "unhandled valkey error")
}
return sessionId, nil
return session, nil
}
const (
@ -67,11 +94,13 @@ func (r *ValkeyRepository) ReadSession(ctx context.Context, sessionId string) (*
return nil, pkg.Wrap(pkg.ErrBadInput, err, op, "uuid validation")
}
sessionIdHash := sha256string(sessionId)
resp := valkey.NewLuaScript(readSessionScript).Exec(
ctx,
r.db,
nil,
[]string{fmt.Sprintf("userid:*:sessionid:%s", sessionId)},
[]string{fmt.Sprintf("userid:*:sessionid:%s", sessionIdHash)},
)
if err = resp.Error(); err != nil {
@ -86,12 +115,20 @@ func (r *ValkeyRepository) ReadSession(ctx context.Context, sessionId string) (*
return nil, pkg.Wrap(pkg.ErrInternal, err, op, "session storage corrupted")
}
session, err := models.ParseSession(str)
var session models.Session
err = json.Unmarshal([]byte(str), &session)
if err != nil {
return nil, pkg.Wrap(pkg.ErrInternal, err, op, "session corrupted")
}
return session, nil
err = session.Valid()
if err != nil {
return nil, pkg.Wrap(pkg.ErrInternal, err, op, "validating session")
}
return &session, nil
}
const (
@ -111,11 +148,13 @@ func (r *ValkeyRepository) UpdateSession(ctx context.Context, sessionId string)
return pkg.Wrap(pkg.ErrBadInput, err, op, "uuid validation")
}
sessionIdHash := sha256string(sessionId)
resp := valkey.NewLuaScript(updateSessionScript).Exec(
ctx,
r.db,
nil,
[]string{fmt.Sprintf("userid:*:sessionid:%s", sessionId), sessionLifetimeString},
[]string{fmt.Sprintf("userid:*:sessionid:%s", sessionIdHash), sessionLifetimeString},
)
err = resp.Error()
@ -140,11 +179,13 @@ func (r *ValkeyRepository) DeleteSession(ctx context.Context, sessionId string)
return pkg.Wrap(pkg.ErrBadInput, err, op, "uuid validation")
}
sessionIdHash := sha256string(sessionId)
resp := valkey.NewLuaScript(deleteSessionScript).Exec(
ctx,
r.db,
nil,
[]string{fmt.Sprintf("userid:*:sessionid:%s", sessionId)},
[]string{fmt.Sprintf("userid:*:sessionid:%s", sessionIdHash)},
)
err = resp.Error()
@ -175,11 +216,13 @@ return dels`
func (r *ValkeyRepository) DeleteAllSessions(ctx context.Context, userId int32) error {
const op = "ValkeyRepository.DeleteAllSessions"
userIdHash := sha256string(strconv.FormatInt(int64(userId), 10))
resp := valkey.NewLuaScript(deleteUserSessionsScript).Exec(
ctx,
r.db,
nil,
[]string{fmt.Sprintf("userid:%d:sessionid:*", userId)},
[]string{fmt.Sprintf("userid:%s:sessionid:*", userIdHash)},
)
err := resp.Error()

View file

@ -11,10 +11,10 @@ type UseCase interface {
ReadUserByUsername(ctx context.Context, username string) (*models.User, error)
UpdateUser(ctx context.Context, id int32, username *string, role *models.Role) error
DeleteUser(ctx context.Context, id int32) error
CreateSession(ctx context.Context, userId int32, role models.Role) (string, error)
CreateSession(ctx context.Context, userId int32, role models.Role, userAgent, ip string) (*models.Session, error)
ReadSession(ctx context.Context, sessionId string) (*models.Session, error)
UpdateSession(ctx context.Context, sessionId string) error
DeleteSession(ctx context.Context, sessionId string) error
DeleteAllSessions(ctx context.Context, userId int32) error
Verify(ctx context.Context, sessionId string) (string, error)
ListUsers(ctx context.Context, page int32, pageSize int32) ([]*models.User, int32, error)
}

View file

@ -7,9 +7,6 @@ import (
"git.sch9.ru/new_gate/ms-auth/internal/models"
"git.sch9.ru/new_gate/ms-auth/internal/users"
"git.sch9.ru/new_gate/ms-auth/pkg"
"github.com/golang-jwt/jwt/v4"
"github.com/google/uuid"
"time"
)
type UseCase struct {
@ -30,20 +27,19 @@ func NewUseCase(
}
}
const (
TokenKey = "token"
)
func (u *UseCase) CreateUser(ctx context.Context, username string, password string, role models.Role) (int32, error) {
const op = "UseCase.CreateUser"
meId, ok := ctx.Value("userId").(int32)
token, ok := ctx.Value(TokenKey).(*models.JWT)
if !ok {
return 0, pkg.Wrap(pkg.ErrUnauthenticated, nil, op, "no user id in context")
return 0, pkg.Wrap(pkg.ErrUnauthenticated, nil, op, "no token in context")
}
me, err := u.userRepo.C().ReadUserById(ctx, meId)
if err != nil {
return 0, pkg.Wrap(nil, err, op, "can't read user by id")
}
if !me.Role.AtLeast(models.RoleModerator) || me.Role.AtMost(role) && !me.Role.IsAdmin() {
if !token.Role.HasPermission(models.Create, models.ResourceAnotherUser) {
return 0, pkg.Wrap(pkg.NoPermission, nil, op, "no permission")
}
@ -58,6 +54,19 @@ func (u *UseCase) CreateUser(ctx context.Context, username string, password stri
func (u *UseCase) ReadUserById(ctx context.Context, id int32) (*models.User, error) {
const op = "UseCase.ReadUserById"
token, ok := ctx.Value(TokenKey).(*models.JWT)
if !ok {
return nil, pkg.Wrap(pkg.ErrUnauthenticated, nil, op, "no token in context")
}
if token.UserId == id && !token.Role.HasPermission(models.Read, models.ResourceMeUser) {
return nil, pkg.Wrap(pkg.NoPermission, nil, op, "no permission")
}
if token.UserId != id && !token.Role.HasPermission(models.Read, models.ResourceAnotherUser) {
return nil, pkg.Wrap(pkg.NoPermission, nil, op, "no permission")
}
user, err := u.userRepo.C().ReadUserById(ctx, id)
if err != nil {
return nil, pkg.Wrap(nil, err, op, "can't read user by id")
@ -65,6 +74,7 @@ func (u *UseCase) ReadUserById(ctx context.Context, id int32) (*models.User, err
return user, nil
}
// ReadUserByUsername is for login only. There are no permission checks! DO NOT USE IT AS AN ENDPOINT RESPONSE!
func (u *UseCase) ReadUserByUsername(ctx context.Context, username string) (*models.User, error) {
const op = "UseCase.ReadUserByUsername"
@ -78,38 +88,16 @@ func (u *UseCase) ReadUserByUsername(ctx context.Context, username string) (*mod
func (u *UseCase) UpdateUser(ctx context.Context, id int32, username *string, role *models.Role) error {
const op = "UseCase.UpdateUser"
meId, ok := ctx.Value("userId").(int32)
token, ok := ctx.Value(TokenKey).(*models.JWT)
if !ok {
return pkg.Wrap(pkg.ErrUnauthenticated, nil, op, "no user id in context")
return pkg.Wrap(pkg.ErrUnauthenticated, nil, op, "no token in context")
}
me, err := u.userRepo.C().ReadUserById(ctx, meId)
if err != nil {
return pkg.Wrap(nil, err, op, "can't read user by id")
if token.UserId == id && !token.Role.HasPermission(models.Update, models.ResourceMeUser) {
return pkg.Wrap(pkg.NoPermission, nil, op, "no permission")
}
user, err := u.userRepo.C().ReadUserById(ctx, id)
if err != nil {
return pkg.Wrap(nil, err, op, "can't read user by id")
}
hasPermission := func() bool {
if me.Id == user.Id && role != nil {
return false
}
if me.Role.IsAdmin() {
return true
}
if role != nil && me.Role.AtMost(*role) {
return false
}
if !me.Role.AtMost(user.Role) {
return true
}
return false
}()
if !hasPermission {
if token.UserId != id && !token.Role.HasPermission(models.Update, models.ResourceAnotherUser) {
return pkg.Wrap(pkg.NoPermission, nil, op, "no permission")
}
@ -137,17 +125,16 @@ func (u *UseCase) UpdateUser(ctx context.Context, id int32, username *string, ro
func (u *UseCase) DeleteUser(ctx context.Context, id int32) error {
const op = "UseCase.DeleteUser"
userId, ok := ctx.Value("userId").(int32)
token, ok := ctx.Value(TokenKey).(*models.JWT)
if !ok {
return pkg.Wrap(pkg.ErrUnauthenticated, nil, op, "no user id in context")
return pkg.Wrap(pkg.ErrUnauthenticated, nil, op, "no token in context")
}
me, err := u.ReadUserById(ctx, userId)
if err != nil {
return pkg.Wrap(nil, err, op, "can't read user by id")
if token.UserId == id && !token.Role.HasPermission(models.Delete, models.ResourceMeUser) {
return pkg.Wrap(pkg.NoPermission, nil, op, "no permission")
}
if me.Id == id || !me.Role.IsAdmin() {
if token.UserId != id && !token.Role.HasPermission(models.Delete, models.ResourceAnotherUser) {
return pkg.Wrap(pkg.NoPermission, nil, op, "no permission")
}
@ -173,17 +160,19 @@ func (u *UseCase) DeleteUser(ctx context.Context, id int32) error {
return nil
}
func (u *UseCase) CreateSession(ctx context.Context, userId int32, role models.Role) (string, error) {
// CreateSession is for login only. There are no permission checks! DO NOT USE IT AS AN ENDPOINT RESPONSE!
func (u *UseCase) CreateSession(ctx context.Context, userId int32, role models.Role, userAgent, ip string) (*models.Session, error) {
const op = "UseCase.CreateSession"
sessionId, err := u.sessionRepo.CreateSession(ctx, userId, role)
session, err := u.sessionRepo.CreateSession(ctx, userId, role, userAgent, ip)
if err != nil {
return "", pkg.Wrap(nil, err, op, "cannot create session")
return nil, pkg.Wrap(nil, err, op, "cannot create session")
}
return sessionId, nil
return session, nil
}
// ReadSession is for internal use only. There are no permission checks! DO NOT USE IT AS AN ENDPOINT RESPONSE!
func (u *UseCase) ReadSession(ctx context.Context, sessionId string) (*models.Session, error) {
const op = "UseCase.ReadSession"
@ -197,6 +186,19 @@ func (u *UseCase) ReadSession(ctx context.Context, sessionId string) (*models.Se
func (u *UseCase) UpdateSession(ctx context.Context, sessionId string) error {
const op = "UseCase.UpdateSession"
token, ok := ctx.Value(TokenKey).(*models.JWT)
if !ok {
return pkg.Wrap(pkg.ErrUnauthenticated, nil, op, "no token in context")
}
if token.SessionId != sessionId {
return pkg.Wrap(pkg.NoPermission, nil, op, "no permission")
}
if token.SessionId == sessionId && !token.Role.HasPermission(models.Update, models.ResourceOwnSession) {
return pkg.Wrap(pkg.NoPermission, nil, op, "no permission")
}
err := u.sessionRepo.UpdateSession(ctx, sessionId)
if err != nil {
return pkg.Wrap(nil, err, op, "cannot update session")
@ -207,6 +209,19 @@ func (u *UseCase) UpdateSession(ctx context.Context, sessionId string) error {
func (u *UseCase) DeleteSession(ctx context.Context, sessionId string) error {
const op = "UseCase.DeleteSession"
token, ok := ctx.Value(TokenKey).(*models.JWT)
if !ok {
return pkg.Wrap(pkg.ErrUnauthenticated, nil, op, "no token in context")
}
if token.SessionId != sessionId {
return pkg.Wrap(pkg.NoPermission, nil, op, "no permission")
}
if token.SessionId == sessionId && !token.Role.HasPermission(models.Delete, models.ResourceOwnSession) {
return pkg.Wrap(pkg.NoPermission, nil, op, "no permission")
}
err := u.sessionRepo.DeleteSession(ctx, sessionId)
if err != nil {
return pkg.Wrap(nil, err, op, "cannot delete session")
@ -217,6 +232,19 @@ func (u *UseCase) DeleteSession(ctx context.Context, sessionId string) error {
func (u *UseCase) DeleteAllSessions(ctx context.Context, userId int32) error {
const op = "UseCase.DeleteAllSessions"
token, ok := ctx.Value(TokenKey).(*models.JWT)
if !ok {
return pkg.Wrap(pkg.ErrUnauthenticated, nil, op, "no token in context")
}
if token.UserId != userId {
return pkg.Wrap(pkg.NoPermission, nil, op, "no permission")
}
if token.UserId == userId && !token.Role.HasPermission(models.Delete, models.ResourceOwnSession) {
return pkg.Wrap(pkg.NoPermission, nil, op, "no permission")
}
err := u.sessionRepo.DeleteAllSessions(ctx, userId)
if err != nil {
return pkg.Wrap(nil, err, op, "cannot delete all sessions")
@ -225,61 +253,21 @@ func (u *UseCase) DeleteAllSessions(ctx context.Context, userId int32) error {
return nil
}
type Token struct {
SessionId string `json:"sid"`
UserId int32 `json:"sub"`
Role models.Role `json:"rle"`
ExpiresAt time.Time `json:"exp"`
IssuedAt time.Time `json:"iat"`
NotBefore time.Time `json:"nbf"`
func (u *UseCase) ListUsers(ctx context.Context, page int32, pageSize int32) ([]*models.User, int32, error) {
const op = "UseCase.ListUsers"
token, ok := ctx.Value(TokenKey).(*models.JWT)
if !ok {
return nil, 0, pkg.Wrap(pkg.ErrUnauthenticated, nil, op, "no token in context")
}
func (t Token) Valid() error {
if err := uuid.Validate(t.SessionId); err != nil {
return err
}
if t.UserId <= 0 {
return errors.New("invalid user id")
}
if t.Role <= 0 {
return errors.New("invalid role")
}
if t.ExpiresAt.IsZero() {
return errors.New("invalid exp")
}
if t.IssuedAt.IsZero() {
return errors.New("invalid iat")
}
if t.NotBefore.IsZero() {
return errors.New("invalid nbf")
}
return nil
if !token.Role.HasPermission(models.Read, models.ResourceAnotherUser) {
return nil, 0, pkg.Wrap(pkg.NoPermission, nil, op, "no permission")
}
func (u *UseCase) Verify(ctx context.Context, sessionId string) (string, error) {
const op = "UseCase.Verify"
session, err := u.sessionRepo.ReadSession(ctx, sessionId)
usersList, count, err := u.userRepo.C().ListUsers(ctx, page, pageSize)
if err != nil {
return "", pkg.Wrap(nil, err, op, "cannot read session")
return nil, 0, pkg.Wrap(nil, err, op, "can't list users")
}
token := jwt.NewWithClaims(
jwt.SigningMethodHS256,
Token{
SessionId: sessionId,
UserId: session.UserId,
Role: session.Role,
ExpiresAt: time.Now().Add(time.Hour * 24),
IssuedAt: time.Now(),
NotBefore: time.Now(),
},
)
signedToken, err := token.SignedString([]byte(u.cfg.JWTSecret))
if err != nil {
return "", pkg.Wrap(pkg.ErrInternal, err, op, "cannot sign token")
}
return signedToken, nil
return usersList, count, nil
}

View file

@ -3,8 +3,7 @@ package pkg
import (
"errors"
"fmt"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"net/http"
)
var (
@ -20,19 +19,19 @@ func Wrap(basic error, err error, op string, msg string) error {
return errors.Join(basic, err, fmt.Errorf("during %s: %s", op, msg))
}
func ToGRPC(err error) error {
func ToREST(err error) int {
switch {
case errors.Is(err, ErrUnauthenticated):
return status.Errorf(codes.Unauthenticated, err.Error())
return http.StatusUnauthorized
case errors.Is(err, ErrBadInput):
return status.Errorf(codes.InvalidArgument, err.Error())
return http.StatusBadRequest
case errors.Is(err, ErrNotFound):
return status.Errorf(codes.NotFound, err.Error())
return http.StatusNotFound
case errors.Is(err, ErrInternal):
return status.Errorf(codes.Internal, err.Error())
return http.StatusInternalServerError
case errors.Is(err, NoPermission):
return status.Errorf(codes.PermissionDenied, err.Error())
return http.StatusForbidden
}
return status.Errorf(codes.Unknown, err.Error())
return http.StatusInternalServerError
}

2
proto

@ -1 +1 @@
Subproject commit 11644adc889ee8f0e0e90c11ccf386a081ee000f
Subproject commit b7ea2e6cc71f7393f9afe722204c5c33b6a6f2b6