├── .gitignore ├── migrations ├── 1_init.down.sql ├── 2_add_is_admin_column_to_users_tbl.down.sql ├── 3_add_app.up.sql ├── 2_add_is_admin_column_to_users_tbl.up.sql └── 1_init.up.sql ├── config ├── config.yaml ├── local_tests.yaml └── prod.yaml ├── internal ├── domain │ └── models │ │ ├── app.go │ │ └── user.go ├── lib │ ├── logger │ │ ├── sl │ │ │ └── sl.go │ │ └── handlers │ │ │ ├── slogdiscard │ │ │ └── slogdiscard.go │ │ │ └── slogpretty │ │ │ └── slogpretty.go │ └── jwt │ │ └── jwt.go ├── storage │ ├── storage.go │ └── sqlite │ │ └── sqlite.go ├── app │ ├── app.go │ └── grpc │ │ └── app.go ├── config │ └── config.go ├── grpc │ └── auth │ │ └── server.go └── services │ └── auth │ └── auth.go ├── tests ├── migrations │ └── 1_init_apps.up.sql ├── suite │ └── suite.go └── auth_register_login_test.go ├── deployment └── grpc-auth.service ├── cmd ├── sso │ └── main.go └── migrator │ └── main.go ├── go.mod ├── .github └── workflows │ └── deploy.yaml └── go.sum /.gitignore: -------------------------------------------------------------------------------- 1 | /storage/ 2 | -------------------------------------------------------------------------------- /migrations/1_init.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS users; 2 | DROP TABLE IF EXISTS apps; 3 | -------------------------------------------------------------------------------- /migrations/2_add_is_admin_column_to_users_tbl.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE users DROP COLUMN is_admin; 2 | -------------------------------------------------------------------------------- /config/config.yaml: -------------------------------------------------------------------------------- 1 | env: "local" 2 | storage_path: "./storage/sso.db" 3 | grpc: 4 | port: 44044 5 | timeout: 5s -------------------------------------------------------------------------------- /config/local_tests.yaml: -------------------------------------------------------------------------------- 1 | env: "local" 2 | storage_path: "./storage/sso.db" 3 | grpc: 4 | port: 44044 5 | timeout: 10h -------------------------------------------------------------------------------- /migrations/3_add_app.up.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO apps (id, name, secret) 2 | VALUES (1, 'test', 'test-secret') 3 | ON CONFLICT DO NOTHING; -------------------------------------------------------------------------------- /migrations/2_add_is_admin_column_to_users_tbl.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE users 2 | ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT FALSE; -------------------------------------------------------------------------------- /internal/domain/models/app.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type App struct { 4 | ID int 5 | Name string 6 | Secret string 7 | } 8 | -------------------------------------------------------------------------------- /tests/migrations/1_init_apps.up.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO apps (id, name, secret) 2 | VALUES (1, 'test', 'test-secret') 3 | ON CONFLICT DO NOTHING; -------------------------------------------------------------------------------- /internal/domain/models/user.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type User struct { 4 | ID int64 5 | Email string 6 | PassHash []byte 7 | } 8 | -------------------------------------------------------------------------------- /config/prod.yaml: -------------------------------------------------------------------------------- 1 | env: "prod" 2 | storage_path: "/root/apps/grpc-auth/sso.db" 3 | grpc: 4 | port: 44044 5 | timeout: 5s 6 | migrations_path: "./migrations" -------------------------------------------------------------------------------- /internal/lib/logger/sl/sl.go: -------------------------------------------------------------------------------- 1 | package sl 2 | 3 | import ( 4 | "log/slog" 5 | ) 6 | 7 | func Err(err error) slog.Attr { 8 | return slog.Attr{ 9 | Key: "error", 10 | Value: slog.StringValue(err.Error()), 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /internal/storage/storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import "errors" 4 | 5 | var ( 6 | ErrUserExists = errors.New("user already exists") 7 | ErrUserNotFound = errors.New("user not found") 8 | ErrAppNotFound = errors.New("app not found") 9 | ) 10 | -------------------------------------------------------------------------------- /deployment/grpc-auth.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=gRPC Auth 3 | After=network.target 4 | 5 | [Service] 6 | User=root 7 | WorkingDirectory=/root/apps/grpc-auth 8 | ExecStart=/root/apps/grpc-auth/grpc-auth --config=/root/apps/grpc-auth/config/prod.yaml 9 | Restart=always 10 | RestartSec=4 11 | StandardOutput=inherit 12 | 13 | [Install] 14 | WantedBy=multi-user.target -------------------------------------------------------------------------------- /migrations/1_init.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS users 2 | ( 3 | id INTEGER PRIMARY KEY, 4 | email TEXT NOT NULL UNIQUE, 5 | pass_hash BLOB NOT NULL 6 | ); 7 | CREATE INDEX IF NOT EXISTS idx_email ON users (email); 8 | 9 | CREATE TABLE IF NOT EXISTS apps 10 | ( 11 | id INTEGER PRIMARY KEY, 12 | name TEXT NOT NULL UNIQUE, 13 | secret TEXT NOT NULL UNIQUE 14 | ); 15 | -------------------------------------------------------------------------------- /internal/app/app.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "log/slog" 5 | "time" 6 | 7 | grpcapp "grpc-service-ref/internal/app/grpc" 8 | "grpc-service-ref/internal/services/auth" 9 | "grpc-service-ref/internal/storage/sqlite" 10 | ) 11 | 12 | type App struct { 13 | GRPCServer *grpcapp.App 14 | } 15 | 16 | func New( 17 | log *slog.Logger, 18 | grpcPort int, 19 | storagePath string, 20 | tokenTTL time.Duration, 21 | ) *App { 22 | storage, err := sqlite.New(storagePath) 23 | if err != nil { 24 | panic(err) 25 | } 26 | 27 | authService := auth.New(log, storage, storage, storage, tokenTTL) 28 | 29 | grpcApp := grpcapp.New(log, authService, grpcPort) 30 | 31 | return &App{ 32 | GRPCServer: grpcApp, 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /internal/lib/jwt/jwt.go: -------------------------------------------------------------------------------- 1 | package jwt 2 | 3 | import ( 4 | "time" 5 | 6 | "grpc-service-ref/internal/domain/models" 7 | 8 | "github.com/golang-jwt/jwt/v5" 9 | ) 10 | 11 | // NewToken creates new JWT token for given user and app. 12 | func NewToken(user models.User, app models.App, duration time.Duration) (string, error) { 13 | token := jwt.New(jwt.SigningMethodHS256) 14 | 15 | claims := token.Claims.(jwt.MapClaims) 16 | claims["uid"] = user.ID 17 | claims["email"] = user.Email 18 | claims["exp"] = time.Now().Add(duration).Unix() 19 | claims["app_id"] = app.ID 20 | 21 | tokenString, err := token.SignedString([]byte(app.Secret)) 22 | if err != nil { 23 | return "", err 24 | } 25 | 26 | return tokenString, nil 27 | } 28 | -------------------------------------------------------------------------------- /internal/lib/logger/handlers/slogdiscard/slogdiscard.go: -------------------------------------------------------------------------------- 1 | package slogdiscard 2 | 3 | import ( 4 | "context" 5 | 6 | "golang.org/x/exp/slog" 7 | ) 8 | 9 | func NewDiscardLogger() *slog.Logger { 10 | return slog.New(NewDiscardHandler()) 11 | } 12 | 13 | type DiscardHandler struct{} 14 | 15 | func NewDiscardHandler() *DiscardHandler { 16 | return &DiscardHandler{} 17 | } 18 | 19 | func (h *DiscardHandler) Handle(_ context.Context, _ slog.Record) error { 20 | // Просто игнорируем запись журнала 21 | return nil 22 | } 23 | 24 | func (h *DiscardHandler) WithAttrs(_ []slog.Attr) slog.Handler { 25 | // Возвращает тот же обработчик, так как нет атрибутов для сохранения 26 | return h 27 | } 28 | 29 | func (h *DiscardHandler) WithGroup(_ string) slog.Handler { 30 | // Возвращает тот же обработчик, так как нет группы для сохранения 31 | return h 32 | } 33 | 34 | func (h *DiscardHandler) Enabled(_ context.Context, _ slog.Level) bool { 35 | // Всегда возвращает false, так как запись журнала игнорируется 36 | return false 37 | } 38 | -------------------------------------------------------------------------------- /cmd/sso/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log/slog" 5 | "os" 6 | "os/signal" 7 | "syscall" 8 | 9 | "grpc-service-ref/internal/app" 10 | "grpc-service-ref/internal/config" 11 | "grpc-service-ref/internal/lib/logger/handlers/slogpretty" 12 | ) 13 | 14 | const ( 15 | envLocal = "local" 16 | envDev = "dev" 17 | envProd = "prod" 18 | ) 19 | 20 | func main() { 21 | cfg := config.MustLoad() 22 | 23 | log := setupLogger(cfg.Env) 24 | 25 | application := app.New(log, cfg.GRPC.Port, cfg.StoragePath, cfg.TokenTTL) 26 | 27 | go func() { 28 | application.GRPCServer.MustRun() 29 | }() 30 | 31 | // Graceful shutdown 32 | 33 | stop := make(chan os.Signal, 1) 34 | signal.Notify(stop, syscall.SIGTERM, syscall.SIGINT) 35 | 36 | <-stop 37 | 38 | application.GRPCServer.Stop() 39 | log.Info("Gracefully stopped") 40 | } 41 | 42 | func setupLogger(env string) *slog.Logger { 43 | var log *slog.Logger 44 | 45 | switch env { 46 | case envLocal: 47 | log = setupPrettySlog() 48 | case envDev: 49 | log = slog.New( 50 | slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}), 51 | ) 52 | case envProd: 53 | log = slog.New( 54 | slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}), 55 | ) 56 | } 57 | 58 | return log 59 | } 60 | 61 | func setupPrettySlog() *slog.Logger { 62 | opts := slogpretty.PrettyHandlerOptions{ 63 | SlogOpts: &slog.HandlerOptions{ 64 | Level: slog.LevelDebug, 65 | }, 66 | } 67 | 68 | handler := opts.NewPrettyHandler(os.Stdout) 69 | 70 | return slog.New(handler) 71 | } 72 | -------------------------------------------------------------------------------- /cmd/migrator/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "fmt" 7 | 8 | "github.com/golang-migrate/migrate/v4" 9 | _ "github.com/golang-migrate/migrate/v4/database/sqlite3" 10 | _ "github.com/golang-migrate/migrate/v4/source/file" 11 | ) 12 | 13 | func main() { 14 | var storagePath, migrationsPath, migrationsTable string 15 | 16 | flag.StringVar(&storagePath, "storage-path", "", "path to storage") 17 | flag.StringVar(&migrationsPath, "migrations-path", "", "path to migrations") 18 | flag.StringVar(&migrationsTable, "migrations-table", "migrations", "name of migrations table") 19 | flag.Parse() 20 | 21 | if storagePath == "" { 22 | panic("storage-path is required") 23 | } 24 | if migrationsPath == "" { 25 | panic("migrations-path is required") 26 | } 27 | 28 | m, err := migrate.New( 29 | "file://"+migrationsPath, 30 | fmt.Sprintf("sqlite3://%s?x-migrations-table=%s", storagePath, migrationsTable), 31 | ) 32 | if err != nil { 33 | panic(err) 34 | } 35 | 36 | if err := m.Up(); err != nil { 37 | if errors.Is(err, migrate.ErrNoChange) { 38 | fmt.Println("no migrations to apply") 39 | 40 | return 41 | } 42 | 43 | panic(err) 44 | } 45 | 46 | fmt.Println("migrations applied") 47 | } 48 | 49 | // Log represents the logger 50 | type Log struct { 51 | verbose bool 52 | } 53 | 54 | // Printf prints out formatted string into a log 55 | func (l *Log) Printf(format string, v ...interface{}) { 56 | fmt.Printf(format, v...) 57 | } 58 | 59 | // Verbose shows if verbose print enabled 60 | func (l *Log) Verbose() bool { 61 | return false 62 | } 63 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "flag" 5 | "os" 6 | "time" 7 | 8 | "github.com/ilyakaznacheev/cleanenv" 9 | ) 10 | 11 | type Config struct { 12 | Env string `yaml:"env" env-default:"local"` 13 | StoragePath string `yaml:"storage_path" env-required:"true"` 14 | GRPC GRPCConfig `yaml:"grpc"` 15 | MigrationsPath string 16 | TokenTTL time.Duration `yaml:"token_ttl" env-default:"1h"` 17 | } 18 | 19 | type GRPCConfig struct { 20 | Port int `yaml:"port"` 21 | Timeout time.Duration `yaml:"timeout"` 22 | } 23 | 24 | func MustLoad() *Config { 25 | configPath := fetchConfigPath() 26 | if configPath == "" { 27 | panic("config path is empty") 28 | } 29 | 30 | return MustLoadPath(configPath) 31 | } 32 | 33 | func MustLoadPath(configPath string) *Config { 34 | // check if file exists 35 | if _, err := os.Stat(configPath); os.IsNotExist(err) { 36 | panic("config file does not exist: " + configPath) 37 | } 38 | 39 | var cfg Config 40 | 41 | if err := cleanenv.ReadConfig(configPath, &cfg); err != nil { 42 | panic("cannot read config: " + err.Error()) 43 | } 44 | 45 | return &cfg 46 | } 47 | 48 | // fetchConfigPath fetches config path from command line flag or environment variable. 49 | // Priority: flag > env > default. 50 | // Default value is empty string. 51 | func fetchConfigPath() string { 52 | var res string 53 | 54 | flag.StringVar(&res, "config", "", "path to config file") 55 | flag.Parse() 56 | 57 | if res == "" { 58 | res = os.Getenv("CONFIG_PATH") 59 | } 60 | 61 | return res 62 | } 63 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module grpc-service-ref 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/JustSkiv/protos v0.0.14 7 | github.com/brianvoe/gofakeit/v6 v6.23.2 8 | github.com/fatih/color v1.15.0 9 | github.com/golang-jwt/jwt/v5 v5.0.0 10 | github.com/golang-migrate/migrate/v4 v4.16.2 11 | github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.0.0 12 | github.com/ilyakaznacheev/cleanenv v1.5.0 13 | github.com/mattn/go-sqlite3 v1.14.17 14 | github.com/stretchr/testify v1.8.4 15 | golang.org/x/crypto v0.13.0 16 | golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 17 | google.golang.org/grpc v1.58.1 18 | ) 19 | 20 | require ( 21 | github.com/BurntSushi/toml v1.2.1 // indirect 22 | github.com/davecgh/go-spew v1.1.1 // indirect 23 | github.com/golang/protobuf v1.5.3 // indirect 24 | github.com/hashicorp/errwrap v1.1.0 // indirect 25 | github.com/hashicorp/go-multierror v1.1.1 // indirect 26 | github.com/joho/godotenv v1.5.1 // indirect 27 | github.com/kr/pretty v0.1.0 // indirect 28 | github.com/mattn/go-colorable v0.1.13 // indirect 29 | github.com/mattn/go-isatty v0.0.17 // indirect 30 | github.com/pmezard/go-difflib v1.0.0 // indirect 31 | go.uber.org/atomic v1.7.0 // indirect 32 | golang.org/x/net v0.14.0 // indirect 33 | golang.org/x/sys v0.12.0 // indirect 34 | golang.org/x/text v0.13.0 // indirect 35 | google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98 // indirect 36 | google.golang.org/protobuf v1.31.0 // indirect 37 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect 38 | gopkg.in/yaml.v3 v3.0.1 // indirect 39 | olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect 40 | ) 41 | -------------------------------------------------------------------------------- /tests/suite/suite.go: -------------------------------------------------------------------------------- 1 | package suite 2 | 3 | import ( 4 | "context" 5 | "net" 6 | "os" 7 | "strconv" 8 | "testing" 9 | 10 | "grpc-service-ref/internal/config" 11 | 12 | ssov1 "github.com/JustSkiv/protos/gen/go/sso" 13 | "google.golang.org/grpc" 14 | "google.golang.org/grpc/credentials/insecure" 15 | ) 16 | 17 | type Suite struct { 18 | *testing.T // Потребуется для вызова методов *testing.T внутри Suite 19 | Cfg *config.Config // Конфигурация приложения 20 | AuthClient ssov1.AuthClient // Клиент для взаимодействия с gRPC-сервером 21 | } 22 | 23 | const ( 24 | grpcHost = "localhost" 25 | ) 26 | 27 | // New creates new test suite. 28 | // 29 | // TODO: for pipeline tests we need to wait for app is ready 30 | func New(t *testing.T) (context.Context, *Suite) { 31 | t.Helper() 32 | t.Parallel() 33 | 34 | cfg := config.MustLoadPath(configPath()) 35 | 36 | ctx, cancelCtx := context.WithTimeout(context.Background(), cfg.GRPC.Timeout) 37 | 38 | t.Cleanup(func() { 39 | t.Helper() 40 | cancelCtx() 41 | }) 42 | 43 | cc, err := grpc.DialContext(context.Background(), 44 | grpcAddress(cfg), 45 | grpc.WithTransportCredentials(insecure.NewCredentials())) // Используем insecure-коннект для тестов 46 | if err != nil { 47 | t.Fatalf("grpc server connection failed: %v", err) 48 | } 49 | 50 | return ctx, &Suite{ 51 | T: t, 52 | Cfg: cfg, 53 | AuthClient: ssov1.NewAuthClient(cc), 54 | } 55 | } 56 | 57 | func configPath() string { 58 | const key = "CONFIG_PATH" 59 | 60 | if v := os.Getenv(key); v != "" { 61 | return v 62 | } 63 | 64 | return "../config/local_tests.yaml" 65 | } 66 | 67 | func grpcAddress(cfg *config.Config) string { 68 | return net.JoinHostPort(grpcHost, strconv.Itoa(cfg.GRPC.Port)) 69 | } 70 | -------------------------------------------------------------------------------- /internal/lib/logger/handlers/slogpretty/slogpretty.go: -------------------------------------------------------------------------------- 1 | package slogpretty 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "io" 7 | stdLog "log" 8 | "log/slog" 9 | 10 | "github.com/fatih/color" 11 | ) 12 | 13 | type PrettyHandlerOptions struct { 14 | SlogOpts *slog.HandlerOptions 15 | } 16 | 17 | type PrettyHandler struct { 18 | opts PrettyHandlerOptions 19 | slog.Handler 20 | l *stdLog.Logger 21 | attrs []slog.Attr 22 | } 23 | 24 | func (opts PrettyHandlerOptions) NewPrettyHandler( 25 | out io.Writer, 26 | ) *PrettyHandler { 27 | h := &PrettyHandler{ 28 | Handler: slog.NewJSONHandler(out, opts.SlogOpts), 29 | l: stdLog.New(out, "", 0), 30 | } 31 | 32 | return h 33 | } 34 | 35 | func (h *PrettyHandler) Handle(_ context.Context, r slog.Record) error { 36 | level := r.Level.String() + ":" 37 | 38 | switch r.Level { 39 | case slog.LevelDebug: 40 | level = color.MagentaString(level) 41 | case slog.LevelInfo: 42 | level = color.BlueString(level) 43 | case slog.LevelWarn: 44 | level = color.YellowString(level) 45 | case slog.LevelError: 46 | level = color.RedString(level) 47 | } 48 | 49 | fields := make(map[string]interface{}, r.NumAttrs()) 50 | 51 | r.Attrs(func(a slog.Attr) bool { 52 | fields[a.Key] = a.Value.Any() 53 | 54 | return true 55 | }) 56 | 57 | for _, a := range h.attrs { 58 | fields[a.Key] = a.Value.Any() 59 | } 60 | 61 | var b []byte 62 | var err error 63 | 64 | if len(fields) > 0 { 65 | b, err = json.MarshalIndent(fields, "", " ") 66 | if err != nil { 67 | return err 68 | } 69 | } 70 | 71 | timeStr := r.Time.Format("[15:05:05.000]") 72 | msg := color.CyanString(r.Message) 73 | 74 | h.l.Println( 75 | timeStr, 76 | level, 77 | msg, 78 | color.WhiteString(string(b)), 79 | ) 80 | 81 | return nil 82 | } 83 | 84 | func (h *PrettyHandler) WithAttrs(attrs []slog.Attr) slog.Handler { 85 | return &PrettyHandler{ 86 | Handler: h.Handler, 87 | l: h.l, 88 | attrs: attrs, 89 | } 90 | } 91 | 92 | func (h *PrettyHandler) WithGroup(name string) slog.Handler { 93 | // TODO: implement 94 | return &PrettyHandler{ 95 | Handler: h.Handler.WithGroup(name), 96 | l: h.l, 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /internal/app/grpc/app.go: -------------------------------------------------------------------------------- 1 | package grpcapp 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "net" 8 | 9 | authgrpc "grpc-service-ref/internal/grpc/auth" 10 | 11 | "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/logging" 12 | "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/recovery" 13 | "google.golang.org/grpc" 14 | "google.golang.org/grpc/codes" 15 | "google.golang.org/grpc/status" 16 | ) 17 | 18 | type App struct { 19 | log *slog.Logger 20 | gRPCServer *grpc.Server 21 | port int 22 | } 23 | 24 | // New creates new gRPC server app. 25 | func New( 26 | log *slog.Logger, 27 | authService authgrpc.Auth, 28 | port int, 29 | ) *App { 30 | loggingOpts := []logging.Option{ 31 | logging.WithLogOnEvents( 32 | //logging.StartCall, logging.FinishCall, 33 | logging.PayloadReceived, logging.PayloadSent, 34 | ), 35 | // Add any other option (check functions starting with logging.With). 36 | } 37 | 38 | recoveryOpts := []recovery.Option{ 39 | recovery.WithRecoveryHandler(func(p interface{}) (err error) { 40 | log.Error("Recovered from panic", slog.Any("panic", p)) 41 | 42 | return status.Errorf(codes.Internal, "internal error") 43 | }), 44 | } 45 | 46 | gRPCServer := grpc.NewServer(grpc.ChainUnaryInterceptor( 47 | recovery.UnaryServerInterceptor(recoveryOpts...), 48 | logging.UnaryServerInterceptor(InterceptorLogger(log), loggingOpts...), 49 | )) 50 | 51 | authgrpc.Register(gRPCServer, authService) 52 | 53 | return &App{ 54 | log: log, 55 | gRPCServer: gRPCServer, 56 | port: port, 57 | } 58 | } 59 | 60 | // InterceptorLogger adapts slog logger to interceptor logger. 61 | // This code is simple enough to be copied and not imported. 62 | func InterceptorLogger(l *slog.Logger) logging.Logger { 63 | return logging.LoggerFunc(func(ctx context.Context, lvl logging.Level, msg string, fields ...any) { 64 | l.Log(ctx, slog.Level(lvl), msg, fields...) 65 | }) 66 | } 67 | 68 | // MustRun runs gRPC server and panics if any error occurs. 69 | func (a *App) MustRun() { 70 | if err := a.Run(); err != nil { 71 | panic(err) 72 | } 73 | } 74 | 75 | // Run runs gRPC server. 76 | func (a *App) Run() error { 77 | const op = "grpcapp.Run" 78 | 79 | l, err := net.Listen("tcp", fmt.Sprintf(":%d", a.port)) 80 | if err != nil { 81 | return fmt.Errorf("%s: %w", op, err) 82 | } 83 | 84 | a.log.Info("grpc server started", slog.String("addr", l.Addr().String())) 85 | 86 | if err := a.gRPCServer.Serve(l); err != nil { 87 | return fmt.Errorf("%s: %w", op, err) 88 | } 89 | 90 | return nil 91 | } 92 | 93 | // Stop stops gRPC server. 94 | func (a *App) Stop() { 95 | const op = "grpcapp.Stop" 96 | 97 | a.log.With(slog.String("op", op)). 98 | Info("stopping gRPC server", slog.Int("port", a.port)) 99 | 100 | a.gRPCServer.GracefulStop() 101 | } 102 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yaml: -------------------------------------------------------------------------------- 1 | name: Deploy App 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | tag: 7 | description: 'Tag to deploy' 8 | required: true 9 | 10 | jobs: 11 | deploy: 12 | runs-on: ubuntu-latest 13 | env: 14 | HOST: root@185.10.184.27 15 | DEPLOY_DIRECTORY: /root/apps/grpc-auth 16 | CONFIG_PATH: /root/apps/grpc-auth/config/prod.yaml 17 | 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v2 21 | with: 22 | ref: ${{ github.event.inputs.tag }} 23 | - name: Check if tag exists 24 | run: | 25 | git fetch --all --tags 26 | if ! git tag | grep -q "^${{ github.event.inputs.tag }}$"; then 27 | echo "error: Tag '${{ github.event.inputs.tag }}' not found" 28 | exit 1 29 | fi 30 | - name: Set up Go 31 | uses: actions/setup-go@v2 32 | with: 33 | go-version: 1.21.2 34 | - name: Build app 35 | run: | 36 | go mod download 37 | go build -o grpc-auth ./cmd/sso 38 | - name: Build migrator 39 | run: | 40 | go build -o migrator ./cmd/migrator 41 | - name: Deploy to VM 42 | run: | 43 | sudo apt-get install -y ssh rsync 44 | echo "$DEPLOY_SSH_KEY" > deploy_key.pem 45 | chmod 600 deploy_key.pem 46 | ssh -i deploy_key.pem -o StrictHostKeyChecking=no ${{ env.HOST }} "mkdir -p ${{ env.DEPLOY_DIRECTORY }}" 47 | rsync -avz -e 'ssh -i deploy_key.pem -o StrictHostKeyChecking=no' --exclude='.git' ./ ${{ env.HOST }}:${{ env.DEPLOY_DIRECTORY }} 48 | rsync -avz -e 'ssh -i deploy_key.pem -o StrictHostKeyChecking=no' ./migrator ${{ env.HOST }}:${{ env.DEPLOY_DIRECTORY }}/migrator 49 | env: 50 | DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} 51 | - name: Remove old systemd service file 52 | run: | 53 | ssh -i deploy_key.pem -o StrictHostKeyChecking=no ${{ env.HOST }} "rm -f /etc/systemd/system/grpc-auth.service" 54 | - name: List workspace contents 55 | run: | 56 | echo "Listing deployment folder contents:" 57 | ls -la ${{ github.workspace }}/deployment 58 | - name: Copy systemd service file 59 | run: | 60 | scp -i deploy_key.pem -o StrictHostKeyChecking=no ${{ github.workspace }}/deployment/grpc-auth.service ${{ env.HOST }}:/tmp/grpc-auth.service 61 | ssh -i deploy_key.pem -o StrictHostKeyChecking=no ${{ env.HOST }} "mv /tmp/grpc-auth.service /etc/systemd/system/grpc-auth.service" 62 | - name: Run migrations 63 | run: | 64 | ssh -i deploy_key.pem -o StrictHostKeyChecking=no ${{ env.HOST }} "${{ env.DEPLOY_DIRECTORY }}/migrator --storage-path=${{ env.DEPLOY_DIRECTORY }}/sso.db --migrations-path=${{ env.DEPLOY_DIRECTORY }}/migrations" 65 | - name: Start application 66 | run: | 67 | ssh -i deploy_key.pem -o StrictHostKeyChecking=no ${{ env.HOST }} "systemctl daemon-reload && systemctl restart grpc-auth.service" -------------------------------------------------------------------------------- /internal/grpc/auth/server.go: -------------------------------------------------------------------------------- 1 | package authgrpc 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "grpc-service-ref/internal/services/auth" 8 | "grpc-service-ref/internal/storage" 9 | 10 | ssov1 "github.com/JustSkiv/protos/gen/go/sso" 11 | "google.golang.org/grpc" 12 | "google.golang.org/grpc/codes" 13 | "google.golang.org/grpc/status" 14 | ) 15 | 16 | type Auth interface { 17 | Login( 18 | ctx context.Context, 19 | email string, 20 | password string, 21 | appID int, 22 | ) (token string, err error) 23 | RegisterNewUser( 24 | ctx context.Context, 25 | email string, 26 | password string, 27 | ) (userID int64, err error) 28 | IsAdmin(ctx context.Context, userID int64) (bool, error) 29 | } 30 | 31 | type serverAPI struct { 32 | ssov1.UnimplementedAuthServer 33 | auth Auth 34 | } 35 | 36 | func Register(gRPCServer *grpc.Server, auth Auth) { 37 | ssov1.RegisterAuthServer(gRPCServer, &serverAPI{auth: auth}) 38 | } 39 | 40 | func (s *serverAPI) Login( 41 | ctx context.Context, 42 | in *ssov1.LoginRequest, 43 | ) (*ssov1.LoginResponse, error) { 44 | if in.Email == "" { 45 | return nil, status.Error(codes.InvalidArgument, "email is required") 46 | } 47 | 48 | if in.Password == "" { 49 | return nil, status.Error(codes.InvalidArgument, "password is required") 50 | } 51 | 52 | if in.GetAppId() == 0 { 53 | return nil, status.Error(codes.InvalidArgument, "app_id is required") 54 | } 55 | 56 | token, err := s.auth.Login(ctx, in.GetEmail(), in.GetPassword(), int(in.GetAppId())) 57 | if err != nil { 58 | if errors.Is(err, auth.ErrInvalidCredentials) { 59 | return nil, status.Error(codes.InvalidArgument, "invalid email or password") 60 | } 61 | 62 | return nil, status.Error(codes.Internal, "failed to login") 63 | } 64 | 65 | return &ssov1.LoginResponse{Token: token}, nil 66 | } 67 | 68 | func (s *serverAPI) Register( 69 | ctx context.Context, 70 | in *ssov1.RegisterRequest, 71 | ) (*ssov1.RegisterResponse, error) { 72 | if in.Email == "" { 73 | return nil, status.Error(codes.InvalidArgument, "email is required") 74 | } 75 | 76 | if in.Password == "" { 77 | return nil, status.Error(codes.InvalidArgument, "password is required") 78 | } 79 | 80 | uid, err := s.auth.RegisterNewUser(ctx, in.GetEmail(), in.GetPassword()) 81 | if err != nil { 82 | if errors.Is(err, storage.ErrUserExists) { 83 | return nil, status.Error(codes.AlreadyExists, "user already exists") 84 | } 85 | 86 | return nil, status.Error(codes.Internal, "failed to register user") 87 | } 88 | 89 | return &ssov1.RegisterResponse{UserId: uid}, nil 90 | } 91 | 92 | func (s *serverAPI) IsAdmin( 93 | ctx context.Context, 94 | in *ssov1.IsAdminRequest, 95 | ) (*ssov1.IsAdminResponse, error) { 96 | if in.UserId == 0 { 97 | return nil, status.Error(codes.InvalidArgument, "user_id is required") 98 | } 99 | 100 | isAdmin, err := s.auth.IsAdmin(ctx, in.GetUserId()) 101 | if err != nil { 102 | if errors.Is(err, storage.ErrUserNotFound) { 103 | return nil, status.Error(codes.NotFound, "user not found") 104 | } 105 | 106 | return nil, status.Error(codes.Internal, "failed to check admin status") 107 | } 108 | 109 | return &ssov1.IsAdminResponse{IsAdmin: isAdmin}, nil 110 | } 111 | -------------------------------------------------------------------------------- /internal/storage/sqlite/sqlite.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "errors" 7 | "fmt" 8 | 9 | "grpc-service-ref/internal/domain/models" 10 | "grpc-service-ref/internal/storage" 11 | 12 | "github.com/mattn/go-sqlite3" 13 | ) 14 | 15 | type Storage struct { 16 | db *sql.DB 17 | } 18 | 19 | func New(storagePath string) (*Storage, error) { 20 | const op = "storage.sqlite.New" 21 | 22 | db, err := sql.Open("sqlite3", storagePath) 23 | if err != nil { 24 | return nil, fmt.Errorf("%s: %w", op, err) 25 | } 26 | 27 | return &Storage{db: db}, nil 28 | } 29 | 30 | func (s *Storage) Stop() error { 31 | return s.db.Close() 32 | } 33 | 34 | // SaveUser saves user to db. 35 | func (s *Storage) SaveUser(ctx context.Context, email string, passHash []byte) (int64, error) { 36 | const op = "storage.sqlite.SaveUser" 37 | 38 | stmt, err := s.db.Prepare("INSERT INTO users(email, pass_hash) VALUES(?, ?)") 39 | if err != nil { 40 | return 0, fmt.Errorf("%s: %w", op, err) 41 | } 42 | 43 | res, err := stmt.ExecContext(ctx, email, passHash) 44 | if err != nil { 45 | var sqliteErr sqlite3.Error 46 | if errors.As(err, &sqliteErr) && sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique { 47 | return 0, fmt.Errorf("%s: %w", op, storage.ErrUserExists) 48 | } 49 | 50 | return 0, fmt.Errorf("%s: %w", op, err) 51 | } 52 | 53 | id, err := res.LastInsertId() 54 | if err != nil { 55 | return 0, fmt.Errorf("%s: %w", op, err) 56 | } 57 | 58 | return id, nil 59 | } 60 | 61 | // User returns user by email. 62 | func (s *Storage) User(ctx context.Context, email string) (models.User, error) { 63 | const op = "storage.sqlite.User" 64 | 65 | stmt, err := s.db.Prepare("SELECT id, email, pass_hash FROM users WHERE email = ?") 66 | if err != nil { 67 | return models.User{}, fmt.Errorf("%s: %w", op, err) 68 | } 69 | 70 | row := stmt.QueryRowContext(ctx, email) 71 | 72 | var user models.User 73 | err = row.Scan(&user.ID, &user.Email, &user.PassHash) 74 | if err != nil { 75 | if errors.Is(err, sql.ErrNoRows) { 76 | return models.User{}, fmt.Errorf("%s: %w", op, storage.ErrUserNotFound) 77 | } 78 | 79 | return models.User{}, fmt.Errorf("%s: %w", op, err) 80 | } 81 | 82 | return user, nil 83 | } 84 | 85 | //func (s *Storage) SavePermission(ctx context.Context, userID int64, permission models.Permission, appID string) error { 86 | // const op = "storage.sqlite.SavePermission" 87 | // 88 | // stmt, err := s.db.Prepare("INSERT INTO permissions(user_id, permission, app_id) VALUES(?, ?, ?)") 89 | // if err != nil { 90 | // return fmt.Errorf("%s: %w", op, err) 91 | // } 92 | // 93 | // _, err = stmt.ExecContext(ctx, userID, permission, appID) 94 | // if err != nil { 95 | // return fmt.Errorf("%s: %w", op, err) 96 | // } 97 | // 98 | // return nil 99 | //} 100 | 101 | // App returns app by id. 102 | func (s *Storage) App(ctx context.Context, id int) (models.App, error) { 103 | const op = "storage.sqlite.App" 104 | 105 | stmt, err := s.db.Prepare("SELECT id, name, secret FROM apps WHERE id = ?") 106 | if err != nil { 107 | return models.App{}, fmt.Errorf("%s: %w", op, err) 108 | } 109 | 110 | row := stmt.QueryRowContext(ctx, id) 111 | 112 | var app models.App 113 | err = row.Scan(&app.ID, &app.Name, &app.Secret) 114 | if err != nil { 115 | if errors.Is(err, sql.ErrNoRows) { 116 | return models.App{}, fmt.Errorf("%s: %w", op, storage.ErrAppNotFound) 117 | } 118 | 119 | return models.App{}, fmt.Errorf("%s: %w", op, err) 120 | } 121 | 122 | return app, nil 123 | } 124 | 125 | func (s *Storage) IsAdmin(ctx context.Context, userID int64) (bool, error) { 126 | const op = "storage.sqlite.IsAdmin" 127 | 128 | stmt, err := s.db.Prepare("SELECT is_admin FROM users WHERE id = ?") 129 | if err != nil { 130 | return false, fmt.Errorf("%s: %w", op, err) 131 | } 132 | 133 | row := stmt.QueryRowContext(ctx, userID) 134 | 135 | var isAdmin bool 136 | 137 | err = row.Scan(&isAdmin) 138 | if err != nil { 139 | if errors.Is(err, sql.ErrNoRows) { 140 | return false, fmt.Errorf("%s: %w", op, storage.ErrUserNotFound) 141 | } 142 | 143 | return false, fmt.Errorf("%s: %w", op, err) 144 | } 145 | 146 | return isAdmin, nil 147 | } 148 | -------------------------------------------------------------------------------- /internal/services/auth/auth.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log/slog" 8 | "time" 9 | 10 | "grpc-service-ref/internal/domain/models" 11 | "grpc-service-ref/internal/lib/jwt" 12 | "grpc-service-ref/internal/lib/logger/sl" 13 | "grpc-service-ref/internal/storage" 14 | 15 | "golang.org/x/crypto/bcrypt" 16 | ) 17 | 18 | type Auth struct { 19 | log *slog.Logger 20 | usrSaver UserSaver 21 | usrProvider UserProvider 22 | appProvider AppProvider 23 | tokenTTL time.Duration 24 | } 25 | 26 | var ( 27 | ErrInvalidCredentials = errors.New("invalid credentials") 28 | ) 29 | 30 | //go:generate go run github.com/vektra/mockery/v2@v2.28.2 --name=URLSaver 31 | type UserSaver interface { 32 | SaveUser( 33 | ctx context.Context, 34 | email string, 35 | passHash []byte, 36 | ) (uid int64, err error) 37 | } 38 | 39 | type UserProvider interface { 40 | User(ctx context.Context, email string) (models.User, error) 41 | IsAdmin(ctx context.Context, userID int64) (bool, error) 42 | } 43 | 44 | type AppProvider interface { 45 | App(ctx context.Context, appID int) (models.App, error) 46 | } 47 | 48 | func New( 49 | log *slog.Logger, 50 | userSaver UserSaver, 51 | userProvider UserProvider, 52 | appProvider AppProvider, 53 | tokenTTL time.Duration, 54 | ) *Auth { 55 | return &Auth{ 56 | usrSaver: userSaver, 57 | usrProvider: userProvider, 58 | log: log, 59 | appProvider: appProvider, 60 | tokenTTL: tokenTTL, 61 | } 62 | } 63 | 64 | // Login checks if user with given credentials exists in the system and returns access token. 65 | // 66 | // If user exists, but password is incorrect, returns error. 67 | // If user doesn't exist, returns error. 68 | func (a *Auth) Login( 69 | ctx context.Context, 70 | email string, 71 | password string, 72 | appID int, 73 | ) (string, error) { 74 | const op = "Auth.Login" 75 | 76 | log := a.log.With( 77 | slog.String("op", op), 78 | slog.String("username", email), 79 | ) 80 | 81 | log.Info("attempting to login user") 82 | 83 | user, err := a.usrProvider.User(ctx, email) 84 | if err != nil { 85 | if errors.Is(err, storage.ErrUserNotFound) { 86 | a.log.Warn("user not found", sl.Err(err)) 87 | 88 | return "", fmt.Errorf("%s: %w", op, ErrInvalidCredentials) 89 | } 90 | 91 | a.log.Error("failed to get user", sl.Err(err)) 92 | 93 | return "", fmt.Errorf("%s: %w", op, err) 94 | } 95 | 96 | if err := bcrypt.CompareHashAndPassword(user.PassHash, []byte(password)); err != nil { 97 | a.log.Info("invalid credentials", sl.Err(err)) 98 | 99 | return "", fmt.Errorf("%s: %w", op, ErrInvalidCredentials) 100 | } 101 | 102 | app, err := a.appProvider.App(ctx, appID) 103 | if err != nil { 104 | return "", fmt.Errorf("%s: %w", op, err) 105 | } 106 | 107 | log.Info("user logged in successfully") 108 | 109 | token, err := jwt.NewToken(user, app, a.tokenTTL) 110 | if err != nil { 111 | a.log.Error("failed to generate token", sl.Err(err)) 112 | 113 | return "", fmt.Errorf("%s: %w", op, err) 114 | } 115 | 116 | return token, nil 117 | } 118 | 119 | // RegisterNewUser registers new user in the system and returns user ID. 120 | // If user with given username already exists, returns error. 121 | func (a *Auth) RegisterNewUser(ctx context.Context, email string, pass string) (int64, error) { 122 | const op = "Auth.RegisterNewUser" 123 | 124 | log := a.log.With( 125 | slog.String("op", op), 126 | slog.String("email", email), 127 | ) 128 | 129 | log.Info("registering user") 130 | 131 | passHash, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost) 132 | if err != nil { 133 | log.Error("failed to generate password hash", sl.Err(err)) 134 | 135 | return 0, fmt.Errorf("%s: %w", op, err) 136 | } 137 | 138 | id, err := a.usrSaver.SaveUser(ctx, email, passHash) 139 | if err != nil { 140 | log.Error("failed to save user", sl.Err(err)) 141 | 142 | return 0, fmt.Errorf("%s: %w", op, err) 143 | } 144 | 145 | return id, nil 146 | } 147 | 148 | // IsAdmin checks if user is admin. 149 | func (a *Auth) IsAdmin(ctx context.Context, userID int64) (bool, error) { 150 | const op = "Auth.IsAdmin" 151 | 152 | log := a.log.With( 153 | slog.String("op", op), 154 | slog.Int64("user_id", userID), 155 | ) 156 | 157 | log.Info("checking if user is admin") 158 | 159 | isAdmin, err := a.usrProvider.IsAdmin(ctx, userID) 160 | if err != nil { 161 | return false, fmt.Errorf("%s: %w", op, err) 162 | } 163 | 164 | log.Info("checked if user is admin", slog.Bool("is_admin", isAdmin)) 165 | 166 | return isAdmin, nil 167 | } 168 | -------------------------------------------------------------------------------- /tests/auth_register_login_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "grpc-service-ref/tests/suite" 8 | 9 | ssov1 "github.com/JustSkiv/protos/gen/go/sso" 10 | "github.com/brianvoe/gofakeit/v6" 11 | "github.com/golang-jwt/jwt/v5" 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | ) 15 | 16 | const ( 17 | emptyAppID = 0 18 | appID = 1 19 | appSecret = "test-secret" 20 | 21 | passDefaultLen = 10 22 | ) 23 | 24 | // TODO: add token fail validation cases 25 | 26 | func TestRegisterLogin_Login_HappyPath(t *testing.T) { 27 | ctx, st := suite.New(t) 28 | 29 | email := gofakeit.Email() 30 | pass := randomFakePassword() 31 | 32 | respReg, err := st.AuthClient.Register(ctx, &ssov1.RegisterRequest{ 33 | Email: email, 34 | Password: pass, 35 | }) 36 | require.NoError(t, err) 37 | assert.NotEmpty(t, respReg.GetUserId()) 38 | 39 | respLogin, err := st.AuthClient.Login(ctx, &ssov1.LoginRequest{ 40 | Email: email, 41 | Password: pass, 42 | AppId: appID, 43 | }) 44 | require.NoError(t, err) 45 | 46 | token := respLogin.GetToken() 47 | require.NotEmpty(t, token) 48 | 49 | loginTime := time.Now() 50 | 51 | tokenParsed, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) { 52 | return []byte(appSecret), nil 53 | }) 54 | require.NoError(t, err) 55 | 56 | claims, ok := tokenParsed.Claims.(jwt.MapClaims) 57 | require.True(t, ok) 58 | 59 | assert.Equal(t, respReg.GetUserId(), int64(claims["uid"].(float64))) 60 | assert.Equal(t, email, claims["email"].(string)) 61 | assert.Equal(t, appID, int(claims["app_id"].(float64))) 62 | 63 | const deltaSeconds = 1 64 | 65 | // check if exp of token is in correct range, ttl get from st.Cfg.TokenTTL 66 | assert.InDelta(t, loginTime.Add(st.Cfg.TokenTTL).Unix(), claims["exp"].(float64), deltaSeconds) 67 | } 68 | 69 | func TestRegisterLogin_DuplicatedRegistration(t *testing.T) { 70 | ctx, st := suite.New(t) 71 | 72 | email := gofakeit.Email() 73 | pass := randomFakePassword() 74 | 75 | respReg, err := st.AuthClient.Register(ctx, &ssov1.RegisterRequest{ 76 | Email: email, 77 | Password: pass, 78 | }) 79 | require.NoError(t, err) 80 | require.NotEmpty(t, respReg.GetUserId()) 81 | 82 | respReg, err = st.AuthClient.Register(ctx, &ssov1.RegisterRequest{ 83 | Email: email, 84 | Password: pass, 85 | }) 86 | require.Error(t, err) 87 | assert.Empty(t, respReg.GetUserId()) 88 | assert.ErrorContains(t, err, "user already exists") 89 | } 90 | 91 | func TestRegister_FailCases(t *testing.T) { 92 | ctx, st := suite.New(t) 93 | 94 | tests := []struct { 95 | name string 96 | email string 97 | password string 98 | expectedErr string 99 | }{ 100 | { 101 | name: "Register with Empty Password", 102 | email: gofakeit.Email(), 103 | password: "", 104 | expectedErr: "password is required", 105 | }, 106 | { 107 | name: "Register with Empty Email", 108 | email: "", 109 | password: randomFakePassword(), 110 | expectedErr: "email is required", 111 | }, 112 | { 113 | name: "Register with Both Empty", 114 | email: "", 115 | password: "", 116 | expectedErr: "email is required", 117 | }, 118 | } 119 | 120 | for _, tt := range tests { 121 | t.Run(tt.name, func(t *testing.T) { 122 | _, err := st.AuthClient.Register(ctx, &ssov1.RegisterRequest{ 123 | Email: tt.email, 124 | Password: tt.password, 125 | }) 126 | require.Error(t, err) 127 | require.Contains(t, err.Error(), tt.expectedErr) 128 | 129 | }) 130 | } 131 | } 132 | 133 | func TestLogin_FailCases(t *testing.T) { 134 | ctx, st := suite.New(t) 135 | 136 | tests := []struct { 137 | name string 138 | email string 139 | password string 140 | appID int32 141 | expectedErr string 142 | }{ 143 | { 144 | name: "Login with Empty Password", 145 | email: gofakeit.Email(), 146 | password: "", 147 | appID: appID, 148 | expectedErr: "password is required", 149 | }, 150 | { 151 | name: "Login with Empty Email", 152 | email: "", 153 | password: randomFakePassword(), 154 | appID: appID, 155 | expectedErr: "email is required", 156 | }, 157 | { 158 | name: "Login with Both Empty Email and Password", 159 | email: "", 160 | password: "", 161 | appID: appID, 162 | expectedErr: "email is required", 163 | }, 164 | { 165 | name: "Login with Non-Matching Password", 166 | email: gofakeit.Email(), 167 | password: randomFakePassword(), 168 | appID: appID, 169 | expectedErr: "invalid email or password", 170 | }, 171 | { 172 | name: "Login without AppID", 173 | email: gofakeit.Email(), 174 | password: randomFakePassword(), 175 | appID: emptyAppID, 176 | expectedErr: "app_id is required", 177 | }, 178 | } 179 | 180 | for _, tt := range tests { 181 | t.Run(tt.name, func(t *testing.T) { 182 | _, err := st.AuthClient.Register(ctx, &ssov1.RegisterRequest{ 183 | Email: gofakeit.Email(), 184 | Password: randomFakePassword(), 185 | }) 186 | require.NoError(t, err) 187 | 188 | _, err = st.AuthClient.Login(ctx, &ssov1.LoginRequest{ 189 | Email: tt.email, 190 | Password: tt.password, 191 | AppId: tt.appID, 192 | }) 193 | require.Error(t, err) 194 | require.Contains(t, err.Error(), tt.expectedErr) 195 | }) 196 | } 197 | } 198 | 199 | func randomFakePassword() string { 200 | return gofakeit.Password(true, true, true, true, false, passDefaultLen) 201 | } 202 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak= 2 | github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= 3 | github.com/JustSkiv/protos v0.0.14 h1:XxIpYO26Va2Q3DKVitTSuqbcbgMnuvkkj29RT3jsn4w= 4 | github.com/JustSkiv/protos v0.0.14/go.mod h1:/OZimabwCueere9/bpTcKf8rvsSiqQZ0/MQ7YlHuKQE= 5 | github.com/brianvoe/gofakeit/v6 v6.23.2 h1:lVde18uhad5wII/f5RMVFLtdQNE0HaGFuBUXmYKk8i8= 6 | github.com/brianvoe/gofakeit/v6 v6.23.2/go.mod h1:Ow6qC71xtwm79anlwKRlWZW6zVq9D2XHE4QSSMP/rU8= 7 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 9 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= 11 | github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= 12 | github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE= 13 | github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 14 | github.com/golang-migrate/migrate/v4 v4.16.2 h1:8coYbMKUyInrFk1lfGfRovTLAW7PhWp8qQDT2iKfuoA= 15 | github.com/golang-migrate/migrate/v4 v4.16.2/go.mod h1:pfcJX4nPHaVdc5nmdCikFBWtm+UBpiZjRNNsyBbp0/o= 16 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 17 | github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= 18 | github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 19 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 20 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 21 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 22 | github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.0.0 h1:2cz5kSrxzMYHiWOBbKj8itQm+nRykkB8aMv4ThcHYHA= 23 | github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.0.0/go.mod h1:w9Y7gY31krpLmrVU5ZPG9H7l9fZuRu5/3R3S3FMtVQ4= 24 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 25 | github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= 26 | github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 27 | github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= 28 | github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= 29 | github.com/ilyakaznacheev/cleanenv v1.5.0 h1:0VNZXggJE2OYdXE87bfSSwGxeiGt9moSR2lOrsHHvr4= 30 | github.com/ilyakaznacheev/cleanenv v1.5.0/go.mod h1:a5aDzaJrLCQZsazHol1w8InnDcOX0OColm64SlIi6gk= 31 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 32 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 33 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 34 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 35 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 36 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 37 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 38 | github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8= 39 | github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 40 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 41 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 42 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 43 | github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= 44 | github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 45 | github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= 46 | github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= 47 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 48 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 49 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 50 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 51 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 52 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 53 | go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= 54 | go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 55 | golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck= 56 | golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= 57 | golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 h1:k/i9J1pBpvlfR+9QsetwPyERsqu1GIbi967PQMq3Ivc= 58 | golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= 59 | golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14= 60 | golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= 61 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 62 | golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= 63 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 64 | golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= 65 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 66 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 67 | google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98 h1:bVf09lpb+OJbByTj913DRJioFFAjf/ZGxEz7MajTp2U= 68 | google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98/go.mod h1:TUfxEVdsvPg18p6AslUXFoLdpED4oBnGwyqk3dV1XzM= 69 | google.golang.org/grpc v1.58.1 h1:OL+Vz23DTtrrldqHK49FUOPHyY75rvFqJfXC84NYW58= 70 | google.golang.org/grpc v1.58.1/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0= 71 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 72 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 73 | google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= 74 | google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 75 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 76 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= 77 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 78 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 79 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 80 | olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 h1:slmdOY3vp8a7KQbHkL+FLbvbkgMqmXojpFUO/jENuqQ= 81 | olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3/go.mod h1:oVgVk4OWVDi43qWBEyGhXgYxt7+ED4iYNpTngSLX2Iw= 82 | --------------------------------------------------------------------------------