From c0cfe20445190cfdec5cd74648a96aa3215ad990 Mon Sep 17 00:00:00 2001 From: yyasha Date: Sun, 17 Dec 2023 13:17:55 +0300 Subject: [PATCH] init --- .gitignore | 1 + Makefile | 6 + cmd/migrator/main.go | 44 ++ cmd/sso/main.go | 77 +++ config/local.yaml | 6 + gen/sso/sso.pb.go | 501 ++++++++++++++++++ gen/sso/sso_grpc.pb.go | 183 +++++++ go.mod | 36 ++ go.sum | 76 +++ internal/app/app.go | 28 + internal/app/grpc/app.go | 63 +++ internal/config/config.go | 55 ++ internal/domain/models/app.go | 7 + internal/domain/models/user.go | 7 + internal/grpc/auth/server.go | 102 ++++ internal/lib/jwt/jwt.go | 25 + internal/lib/logger/sl/sl.go | 10 + .../lib/logger/slogdiscard/slogdiscard.go | 40 ++ internal/lib/logger/slogpretty/slogpretty.go | 99 ++++ internal/services/auth/auth.go | 162 ++++++ internal/storage/sqlite/sqlite.go | 118 +++++ internal/storage/storage.go | 9 + migrations/1_init.down.sql | 2 + migrations/1_init.up.sql | 12 + ..._add_is_admin_column_to_users_tbl.down.sql | 1 + .../2_add_is_admin_column_to_users_tbl.up.sql | 1 + proto/sso/sso.proto | 38 ++ tests/auth_register_login_test.go | 200 +++++++ tests/migrations/1_init_apps.up.sql | 1 + tests/suite/suite.go | 53 ++ 30 files changed, 1963 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 cmd/migrator/main.go create mode 100644 cmd/sso/main.go create mode 100644 config/local.yaml create mode 100644 gen/sso/sso.pb.go create mode 100644 gen/sso/sso_grpc.pb.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/app/app.go create mode 100644 internal/app/grpc/app.go create mode 100644 internal/config/config.go create mode 100644 internal/domain/models/app.go create mode 100644 internal/domain/models/user.go create mode 100644 internal/grpc/auth/server.go create mode 100644 internal/lib/jwt/jwt.go create mode 100644 internal/lib/logger/sl/sl.go create mode 100644 internal/lib/logger/slogdiscard/slogdiscard.go create mode 100644 internal/lib/logger/slogpretty/slogpretty.go create mode 100644 internal/services/auth/auth.go create mode 100644 internal/storage/sqlite/sqlite.go create mode 100644 internal/storage/storage.go create mode 100644 migrations/1_init.down.sql create mode 100644 migrations/1_init.up.sql create mode 100644 migrations/2_add_is_admin_column_to_users_tbl.down.sql create mode 100644 migrations/2_add_is_admin_column_to_users_tbl.up.sql create mode 100644 proto/sso/sso.proto create mode 100644 tests/auth_register_login_test.go create mode 100644 tests/migrations/1_init_apps.up.sql create mode 100644 tests/suite/suite.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..98e6ef6 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.db diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..88027d4 --- /dev/null +++ b/Makefile @@ -0,0 +1,6 @@ +run: + go run ./cmd/sso/main.go -config ./config/local.yaml +gen_proto: + protoc -I proto proto/sso/*.proto --go_out=./gen --go_opt=paths=source_relative --go-grpc_out=./gen --go-grpc_opt=paths=source_relative +migrate: + go run ./cmd/migrator/main.go -migrations-path ./migrations -storage-path ./storage/sso.db -migrations-table migrations diff --git a/cmd/migrator/main.go b/cmd/migrator/main.go new file mode 100644 index 0000000..3a41cc0 --- /dev/null +++ b/cmd/migrator/main.go @@ -0,0 +1,44 @@ +package main + +import ( + "errors" + "flag" + "fmt" + + "github.com/golang-migrate/migrate/v4" + _ "github.com/golang-migrate/migrate/v4/database/sqlite3" + _ "github.com/golang-migrate/migrate/v4/source/file" +) + +func main() { + var storagePath, migrationsPath, migtrationsTable string + + flag.StringVar(&storagePath, "storage-path", "", "path to db file") + flag.StringVar(&migrationsPath, "migrations-path", "", "path to migrations") + flag.StringVar(&migtrationsTable, "migrations-table", "migrations", "name of migrations table") + + flag.Parse() + + if storagePath == "" { + panic("storage-path is required") + } + if migrationsPath == "" { + panic("migrations-path") + } + + m, err := migrate.New("file://"+migrationsPath, fmt.Sprintf("sqlite3://%s?x-migrations-table=%s", storagePath, migtrationsTable)) + if err != nil { + panic(err) + } + + if err := m.Up(); err != nil { + if errors.Is(err, migrate.ErrNoChange) { + fmt.Println("no migrations apply") + return + } + + panic(err) + } + + fmt.Println("migrations applied successfully") +} diff --git a/cmd/sso/main.go b/cmd/sso/main.go new file mode 100644 index 0000000..14cb984 --- /dev/null +++ b/cmd/sso/main.go @@ -0,0 +1,77 @@ +package main + +import ( + "log/slog" + "os" + "os/signal" + "sso/internal/app" + "sso/internal/config" + "sso/internal/lib/logger/slogpretty" + "syscall" +) + +const ( + envLocal = "local" + envDev = "dev" + envProd = "prod" +) + +func main() { + cfg := config.MustLoad() + + // инициализировать логгер + log := setupLogger(cfg.Env) + + log.Info("starting application", slog.String("env", cfg.Env)) + + application := app.New(log, cfg.GRPC.Port, cfg.StoragePath, cfg.TokenTTL) + + go application.GRPCSrv.MustRun() + + // инициализировать приложение + + // запустить gRPC-сервер приложения + + // Graceful shutdown + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGTERM, syscall.SIGINT) + + <-stop + + log.Info("stopping application...") + + application.GRPCSrv.Stop() + + log.Info("application stopped") +} + +func setupLogger(env string) *slog.Logger { + var log *slog.Logger + + switch env { + case envLocal: + log = setupPrettySlog() + case envDev: + log = slog.New( + slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}), + ) + case envProd: + log = slog.New( + slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}), + ) + } + + return log +} + +func setupPrettySlog() *slog.Logger { + opts := slogpretty.PrettyHandlerOptions{ + SlogOpts: &slog.HandlerOptions{ + Level: slog.LevelDebug, + }, + } + + handler := opts.NewPrettyHandler(os.Stdout) + + return slog.New(handler) +} diff --git a/config/local.yaml b/config/local.yaml new file mode 100644 index 0000000..320c06c --- /dev/null +++ b/config/local.yaml @@ -0,0 +1,6 @@ +env: "local" # dev, prod +storage_path: "./storage/sso.db" +token_ttl: 1h +grpc: + port: 44044 + timeout: 10m \ No newline at end of file diff --git a/gen/sso/sso.pb.go b/gen/sso/sso.pb.go new file mode 100644 index 0000000..abbeb02 --- /dev/null +++ b/gen/sso/sso.pb.go @@ -0,0 +1,501 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.31.0 +// protoc v4.25.1 +// source: sso/sso.proto + +package ssov1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type RegisterRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Email string `protobuf:"bytes,1,opt,name=email,proto3" json:"email,omitempty"` // Email of the user to register + Password string `protobuf:"bytes,2,opt,name=password,proto3" json:"password,omitempty"` // User ID of the registered user +} + +func (x *RegisterRequest) Reset() { + *x = RegisterRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_sso_sso_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *RegisterRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RegisterRequest) ProtoMessage() {} + +func (x *RegisterRequest) ProtoReflect() protoreflect.Message { + mi := &file_sso_sso_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RegisterRequest.ProtoReflect.Descriptor instead. +func (*RegisterRequest) Descriptor() ([]byte, []int) { + return file_sso_sso_proto_rawDescGZIP(), []int{0} +} + +func (x *RegisterRequest) GetEmail() string { + if x != nil { + return x.Email + } + return "" +} + +func (x *RegisterRequest) GetPassword() string { + if x != nil { + return x.Password + } + return "" +} + +type RegisterResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` // User ID of the registered user. +} + +func (x *RegisterResponse) Reset() { + *x = RegisterResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_sso_sso_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *RegisterResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RegisterResponse) ProtoMessage() {} + +func (x *RegisterResponse) ProtoReflect() protoreflect.Message { + mi := &file_sso_sso_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RegisterResponse.ProtoReflect.Descriptor instead. +func (*RegisterResponse) Descriptor() ([]byte, []int) { + return file_sso_sso_proto_rawDescGZIP(), []int{1} +} + +func (x *RegisterResponse) GetUserId() int64 { + if x != nil { + return x.UserId + } + return 0 +} + +type LoginRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Email string `protobuf:"bytes,1,opt,name=email,proto3" json:"email,omitempty"` // Email of the user to login. + Password string `protobuf:"bytes,2,opt,name=password,proto3" json:"password,omitempty"` // Password of the user to login. + AppId int32 `protobuf:"varint,3,opt,name=app_id,json=appId,proto3" json:"app_id,omitempty"` // ID of the app to login to. +} + +func (x *LoginRequest) Reset() { + *x = LoginRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_sso_sso_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *LoginRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LoginRequest) ProtoMessage() {} + +func (x *LoginRequest) ProtoReflect() protoreflect.Message { + mi := &file_sso_sso_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LoginRequest.ProtoReflect.Descriptor instead. +func (*LoginRequest) Descriptor() ([]byte, []int) { + return file_sso_sso_proto_rawDescGZIP(), []int{2} +} + +func (x *LoginRequest) GetEmail() string { + if x != nil { + return x.Email + } + return "" +} + +func (x *LoginRequest) GetPassword() string { + if x != nil { + return x.Password + } + return "" +} + +func (x *LoginRequest) GetAppId() int32 { + if x != nil { + return x.AppId + } + return 0 +} + +type LoginResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Token string `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"` // Auth token of the logged in user. +} + +func (x *LoginResponse) Reset() { + *x = LoginResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_sso_sso_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *LoginResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LoginResponse) ProtoMessage() {} + +func (x *LoginResponse) ProtoReflect() protoreflect.Message { + mi := &file_sso_sso_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LoginResponse.ProtoReflect.Descriptor instead. +func (*LoginResponse) Descriptor() ([]byte, []int) { + return file_sso_sso_proto_rawDescGZIP(), []int{3} +} + +func (x *LoginResponse) GetToken() string { + if x != nil { + return x.Token + } + return "" +} + +type IsAdminRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` // User ID to validate +} + +func (x *IsAdminRequest) Reset() { + *x = IsAdminRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_sso_sso_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *IsAdminRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IsAdminRequest) ProtoMessage() {} + +func (x *IsAdminRequest) ProtoReflect() protoreflect.Message { + mi := &file_sso_sso_proto_msgTypes[4] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IsAdminRequest.ProtoReflect.Descriptor instead. +func (*IsAdminRequest) Descriptor() ([]byte, []int) { + return file_sso_sso_proto_rawDescGZIP(), []int{4} +} + +func (x *IsAdminRequest) GetUserId() int64 { + if x != nil { + return x.UserId + } + return 0 +} + +type IsAdminResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + IsAdmin bool `protobuf:"varint,1,opt,name=is_admin,json=isAdmin,proto3" json:"is_admin,omitempty"` // Indicates whether the user is an admin. +} + +func (x *IsAdminResponse) Reset() { + *x = IsAdminResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_sso_sso_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *IsAdminResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IsAdminResponse) ProtoMessage() {} + +func (x *IsAdminResponse) ProtoReflect() protoreflect.Message { + mi := &file_sso_sso_proto_msgTypes[5] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IsAdminResponse.ProtoReflect.Descriptor instead. +func (*IsAdminResponse) Descriptor() ([]byte, []int) { + return file_sso_sso_proto_rawDescGZIP(), []int{5} +} + +func (x *IsAdminResponse) GetIsAdmin() bool { + if x != nil { + return x.IsAdmin + } + return false +} + +var File_sso_sso_proto protoreflect.FileDescriptor + +var file_sso_sso_proto_rawDesc = []byte{ + 0x0a, 0x0d, 0x73, 0x73, 0x6f, 0x2f, 0x73, 0x73, 0x6f, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, + 0x04, 0x61, 0x75, 0x74, 0x68, 0x22, 0x43, 0x0a, 0x0f, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, + 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69, + 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x1a, + 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x22, 0x2b, 0x0a, 0x10, 0x52, 0x65, + 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x17, + 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, + 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0x57, 0x0a, 0x0c, 0x4c, 0x6f, 0x67, 0x69, 0x6e, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x1a, 0x0a, + 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x15, 0x0a, 0x06, 0x61, 0x70, 0x70, + 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x61, 0x70, 0x70, 0x49, 0x64, + 0x22, 0x25, 0x0a, 0x0d, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x29, 0x0a, 0x0e, 0x49, 0x73, 0x41, 0x64, 0x6d, + 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, + 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, + 0x49, 0x64, 0x22, 0x2c, 0x0a, 0x0f, 0x49, 0x73, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x69, 0x73, 0x5f, 0x61, 0x64, 0x6d, 0x69, + 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x69, 0x73, 0x41, 0x64, 0x6d, 0x69, 0x6e, + 0x32, 0xab, 0x01, 0x0a, 0x04, 0x41, 0x75, 0x74, 0x68, 0x12, 0x39, 0x0a, 0x08, 0x52, 0x65, 0x67, + 0x69, 0x73, 0x74, 0x65, 0x72, 0x12, 0x15, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x2e, 0x52, 0x65, 0x67, + 0x69, 0x73, 0x74, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x61, + 0x75, 0x74, 0x68, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x30, 0x0a, 0x05, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x12, 0x2e, + 0x61, 0x75, 0x74, 0x68, 0x2e, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x13, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x2e, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x36, 0x0a, 0x07, 0x49, 0x73, 0x41, 0x64, 0x6d, 0x69, + 0x6e, 0x12, 0x14, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x2e, 0x49, 0x73, 0x41, 0x64, 0x6d, 0x69, 0x6e, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x15, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x2e, 0x49, + 0x73, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x13, + 0x5a, 0x11, 0x79, 0x61, 0x73, 0x68, 0x2e, 0x73, 0x73, 0x6f, 0x2e, 0x76, 0x31, 0x3b, 0x73, 0x73, + 0x6f, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_sso_sso_proto_rawDescOnce sync.Once + file_sso_sso_proto_rawDescData = file_sso_sso_proto_rawDesc +) + +func file_sso_sso_proto_rawDescGZIP() []byte { + file_sso_sso_proto_rawDescOnce.Do(func() { + file_sso_sso_proto_rawDescData = protoimpl.X.CompressGZIP(file_sso_sso_proto_rawDescData) + }) + return file_sso_sso_proto_rawDescData +} + +var file_sso_sso_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_sso_sso_proto_goTypes = []interface{}{ + (*RegisterRequest)(nil), // 0: auth.RegisterRequest + (*RegisterResponse)(nil), // 1: auth.RegisterResponse + (*LoginRequest)(nil), // 2: auth.LoginRequest + (*LoginResponse)(nil), // 3: auth.LoginResponse + (*IsAdminRequest)(nil), // 4: auth.IsAdminRequest + (*IsAdminResponse)(nil), // 5: auth.IsAdminResponse +} +var file_sso_sso_proto_depIdxs = []int32{ + 0, // 0: auth.Auth.Register:input_type -> auth.RegisterRequest + 2, // 1: auth.Auth.Login:input_type -> auth.LoginRequest + 4, // 2: auth.Auth.IsAdmin:input_type -> auth.IsAdminRequest + 1, // 3: auth.Auth.Register:output_type -> auth.RegisterResponse + 3, // 4: auth.Auth.Login:output_type -> auth.LoginResponse + 5, // 5: auth.Auth.IsAdmin:output_type -> auth.IsAdminResponse + 3, // [3:6] is the sub-list for method output_type + 0, // [0:3] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_sso_sso_proto_init() } +func file_sso_sso_proto_init() { + if File_sso_sso_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_sso_sso_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*RegisterRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sso_sso_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*RegisterResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sso_sso_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*LoginRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sso_sso_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*LoginResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sso_sso_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*IsAdminRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_sso_sso_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*IsAdminResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_sso_sso_proto_rawDesc, + NumEnums: 0, + NumMessages: 6, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_sso_sso_proto_goTypes, + DependencyIndexes: file_sso_sso_proto_depIdxs, + MessageInfos: file_sso_sso_proto_msgTypes, + }.Build() + File_sso_sso_proto = out.File + file_sso_sso_proto_rawDesc = nil + file_sso_sso_proto_goTypes = nil + file_sso_sso_proto_depIdxs = nil +} diff --git a/gen/sso/sso_grpc.pb.go b/gen/sso/sso_grpc.pb.go new file mode 100644 index 0000000..594e01c --- /dev/null +++ b/gen/sso/sso_grpc.pb.go @@ -0,0 +1,183 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.3.0 +// - protoc v4.25.1 +// source: sso/sso.proto + +package ssov1 + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.32.0 or later. +const _ = grpc.SupportPackageIsVersion7 + +const ( + Auth_Register_FullMethodName = "/auth.Auth/Register" + Auth_Login_FullMethodName = "/auth.Auth/Login" + Auth_IsAdmin_FullMethodName = "/auth.Auth/IsAdmin" +) + +// AuthClient is the client API for Auth service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type AuthClient interface { + Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error) + Login(ctx context.Context, in *LoginRequest, opts ...grpc.CallOption) (*LoginResponse, error) + IsAdmin(ctx context.Context, in *IsAdminRequest, opts ...grpc.CallOption) (*IsAdminResponse, error) +} + +type authClient struct { + cc grpc.ClientConnInterface +} + +func NewAuthClient(cc grpc.ClientConnInterface) AuthClient { + return &authClient{cc} +} + +func (c *authClient) Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error) { + out := new(RegisterResponse) + err := c.cc.Invoke(ctx, Auth_Register_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *authClient) Login(ctx context.Context, in *LoginRequest, opts ...grpc.CallOption) (*LoginResponse, error) { + out := new(LoginResponse) + err := c.cc.Invoke(ctx, Auth_Login_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *authClient) IsAdmin(ctx context.Context, in *IsAdminRequest, opts ...grpc.CallOption) (*IsAdminResponse, error) { + out := new(IsAdminResponse) + err := c.cc.Invoke(ctx, Auth_IsAdmin_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +// AuthServer is the server API for Auth service. +// All implementations must embed UnimplementedAuthServer +// for forward compatibility +type AuthServer interface { + Register(context.Context, *RegisterRequest) (*RegisterResponse, error) + Login(context.Context, *LoginRequest) (*LoginResponse, error) + IsAdmin(context.Context, *IsAdminRequest) (*IsAdminResponse, error) + mustEmbedUnimplementedAuthServer() +} + +// UnimplementedAuthServer must be embedded to have forward compatible implementations. +type UnimplementedAuthServer struct { +} + +func (UnimplementedAuthServer) Register(context.Context, *RegisterRequest) (*RegisterResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Register not implemented") +} +func (UnimplementedAuthServer) Login(context.Context, *LoginRequest) (*LoginResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Login not implemented") +} +func (UnimplementedAuthServer) IsAdmin(context.Context, *IsAdminRequest) (*IsAdminResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method IsAdmin not implemented") +} +func (UnimplementedAuthServer) mustEmbedUnimplementedAuthServer() {} + +// UnsafeAuthServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to AuthServer will +// result in compilation errors. +type UnsafeAuthServer interface { + mustEmbedUnimplementedAuthServer() +} + +func RegisterAuthServer(s grpc.ServiceRegistrar, srv AuthServer) { + s.RegisterService(&Auth_ServiceDesc, srv) +} + +func _Auth_Register_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RegisterRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AuthServer).Register(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Auth_Register_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AuthServer).Register(ctx, req.(*RegisterRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Auth_Login_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(LoginRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AuthServer).Login(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Auth_Login_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AuthServer).Login(ctx, req.(*LoginRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Auth_IsAdmin_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(IsAdminRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AuthServer).IsAdmin(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Auth_IsAdmin_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AuthServer).IsAdmin(ctx, req.(*IsAdminRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// Auth_ServiceDesc is the grpc.ServiceDesc for Auth service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var Auth_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "auth.Auth", + HandlerType: (*AuthServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Register", + Handler: _Auth_Register_Handler, + }, + { + MethodName: "Login", + Handler: _Auth_Login_Handler, + }, + { + MethodName: "IsAdmin", + Handler: _Auth_IsAdmin_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "sso/sso.proto", +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a0fe7f0 --- /dev/null +++ b/go.mod @@ -0,0 +1,36 @@ +module sso + +go 1.21.4 + +require ( + github.com/fatih/color v1.16.0 + github.com/golang-jwt/jwt/v5 v5.2.0 + github.com/ilyakaznacheev/cleanenv v1.5.0 + golang.org/x/crypto v0.16.0 + google.golang.org/grpc v1.59.0 + google.golang.org/protobuf v1.31.0 +) + +require ( + github.com/BurntSushi/toml v1.2.1 // indirect + github.com/brianvoe/gofakeit/v6 v6.26.3 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/golang-migrate/migrate/v4 v4.16.2 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.19 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.0 // indirect + github.com/stretchr/testify v1.8.4 // indirect + go.uber.org/atomic v1.7.0 // indirect + golang.org/x/net v0.14.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/text v0.14.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..93c2577 --- /dev/null +++ b/go.sum @@ -0,0 +1,76 @@ +github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak= +github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/brianvoe/gofakeit/v6 v6.26.3 h1:3ljYrjPwsUNAUFdUIr2jVg5EhKdcke/ZLop7uVg1Er8= +github.com/brianvoe/gofakeit/v6 v6.26.3/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= +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/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= +github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-migrate/migrate/v4 v4.16.2 h1:8coYbMKUyInrFk1lfGfRovTLAW7PhWp8qQDT2iKfuoA= +github.com/golang-migrate/migrate/v4 v4.16.2/go.mod h1:pfcJX4nPHaVdc5nmdCikFBWtm+UBpiZjRNNsyBbp0/o= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/ilyakaznacheev/cleanenv v1.5.0 h1:0VNZXggJE2OYdXE87bfSSwGxeiGt9moSR2lOrsHHvr4= +github.com/ilyakaznacheev/cleanenv v1.5.0/go.mod h1:a5aDzaJrLCQZsazHol1w8InnDcOX0OColm64SlIi6gk= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +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-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= +github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI= +github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY= +golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14= +golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= +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.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d h1:uvYuEyMHKNt+lT4K3bN6fGswmK8qSvcreM3BwjDh+y4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d/go.mod h1:+Bk1OCOj40wS2hwAMA+aCW9ypzm63QTBBHp6lQ3p+9M= +google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk= +google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +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= diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..6fbe989 --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,28 @@ +package app + +import ( + "log/slog" + grpcapp "sso/internal/app/grpc" + "sso/internal/services/auth" + "sso/internal/storage/sqlite" + "time" +) + +type App struct { + GRPCSrv *grpcapp.App +} + +func New(log *slog.Logger, grpcPort int, storagePath string, tokenTTL time.Duration) *App { + storage, err := sqlite.New(storagePath) + if err != nil { + panic(err) + } + + authService := auth.New(log, storage, storage, storage, tokenTTL) + + grpcApp := grpcapp.New(log, authService, grpcPort) + + return &App{ + GRPCSrv: grpcApp, + } +} diff --git a/internal/app/grpc/app.go b/internal/app/grpc/app.go new file mode 100644 index 0000000..580a699 --- /dev/null +++ b/internal/app/grpc/app.go @@ -0,0 +1,63 @@ +package grpcapp + +import ( + "fmt" + "log/slog" + "net" + authgrpc "sso/internal/grpc/auth" + + "google.golang.org/grpc" +) + +type App struct { + log *slog.Logger + gRPCServer *grpc.Server + port int +} + +func New(log *slog.Logger, authService authgrpc.Auth, port int) *App { + gRPCServer := grpc.NewServer() + + authgrpc.Register(gRPCServer, authService) + + return &App{ + log: log, + gRPCServer: gRPCServer, + port: port, + } +} + +func (a *App) MustRun() { + if err := a.Run(); err != nil { + panic(err) + } +} + +func (a *App) Run() error { + const op = "grpcapp.Run" + + log := a.log.With( + slog.String("op", op), + ) + + l, err := net.Listen("tcp", fmt.Sprintf(":%d", a.port)) + if err != nil { + return fmt.Errorf("%s: %w", op, err) + } + + log.Info("gRPC server is running", slog.String("addr", l.Addr().String())) + + if err := a.gRPCServer.Serve(l); err != nil { + return fmt.Errorf("%s: %w", op, err) + } + + return nil +} + +func (a *App) Stop() { + const op = "grpcapp.Stop" + + a.log.With(slog.String("op", op)).Info("stopping gRPC server") + + a.gRPCServer.GracefulStop() +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..272f4ed --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,55 @@ +package config + +import ( + "flag" + "os" + "time" + + "github.com/ilyakaznacheev/cleanenv" +) + +type Config struct { + Env string `yaml:"env" env-required:"true"` + StoragePath string `yaml:"storage_path" env-required:"true"` + TokenTTL time.Duration `yaml:"token_ttl" env-default:"1h"` + GRPC struct { + Port int `yaml:"port" env-default:"44044"` + Timeout time.Duration `yaml:"timeout" env-default:"10s"` + } `yaml:"grpc"` +} + +func MustLoad() *Config { + path := fetchConfigPath() + if path == "" { + panic("config path is empty") + } + + return MustLoadByPath(path) +} + +func MustLoadByPath(configPath string) *Config { + if _, err := os.Stat(configPath); os.IsNotExist(err) { + panic("config path does not exist: " + configPath) + } + + var cfg Config + if err := cleanenv.ReadConfig(configPath, &cfg); err != nil { + panic(err) + } + + return &cfg +} + +func fetchConfigPath() string { + var res string + + // --config="path/to/config.yaml" + flag.StringVar(&res, "config", "", "path to config file") + flag.Parse() + + if res == "" { + res = os.Getenv("CONFIG_PATH") + } + + return res +} diff --git a/internal/domain/models/app.go b/internal/domain/models/app.go new file mode 100644 index 0000000..74382e5 --- /dev/null +++ b/internal/domain/models/app.go @@ -0,0 +1,7 @@ +package models + +type App struct { + ID int + Name string + Secret string +} diff --git a/internal/domain/models/user.go b/internal/domain/models/user.go new file mode 100644 index 0000000..59b03e1 --- /dev/null +++ b/internal/domain/models/user.go @@ -0,0 +1,7 @@ +package models + +type User struct { + ID int64 + Email string + PassHash []byte +} diff --git a/internal/grpc/auth/server.go b/internal/grpc/auth/server.go new file mode 100644 index 0000000..9dabcdd --- /dev/null +++ b/internal/grpc/auth/server.go @@ -0,0 +1,102 @@ +package auth + +import ( + "context" + "errors" + ssov1 "sso/gen/sso" + "sso/internal/services/auth" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type Auth interface { + Login(ctx context.Context, email string, password string, appID int) (token string, err error) + RegisterNewUser(ctx context.Context, email string, password string) (userID int64, err error) + IsAdmin(ctx context.Context, userID int64) (bool, error) +} + +type serverAPI struct { + ssov1.UnimplementedAuthServer + auth Auth +} + +func Register(gRPC *grpc.Server, auth Auth) { + ssov1.RegisterAuthServer(gRPC, &serverAPI{auth: auth}) +} + +const ( + emptyValue = 0 +) + +func (s *serverAPI) Login(ctx context.Context, req *ssov1.LoginRequest) (*ssov1.LoginResponse, error) { + // validate recieved data + if req.GetEmail() == "" { + return nil, status.Error(codes.InvalidArgument, "email is required") + } + + if req.GetPassword() == "" { + return nil, status.Error(codes.InvalidArgument, "password is required") + } + + if req.GetAppId() == emptyValue { + return nil, status.Error(codes.InvalidArgument, "app_id is required") + } + + // login via auth service + token, err := s.auth.Login(ctx, req.GetEmail(), req.GetPassword(), int(req.GetAppId())) + if err != nil { + if errors.Is(err, auth.ErrInvalidCredentials) { + return nil, status.Error(codes.InvalidArgument, "invalid credentials") + } + + return nil, status.Error(codes.Internal, "internal error") + } + + return &ssov1.LoginResponse{ + Token: token, + }, nil +} + +func (s *serverAPI) Register(ctx context.Context, req *ssov1.RegisterRequest) (*ssov1.RegisterResponse, error) { + // validate fields + if req.GetEmail() == "" { + return nil, status.Error(codes.InvalidArgument, "email is required") + } + if req.GetPassword() == "" { + return nil, status.Error(codes.InvalidArgument, "password is required") + } + + userID, err := s.auth.RegisterNewUser(ctx, req.GetEmail(), req.GetPassword()) + if err != nil { + if errors.Is(err, auth.ErrUserExists) { + return nil, status.Error(codes.AlreadyExists, "user already exists") + } + + return nil, status.Error(codes.Internal, "internal error") + } + + return &ssov1.RegisterResponse{ + UserId: userID, + }, nil +} + +func (s *serverAPI) IsAdmin(ctx context.Context, req *ssov1.IsAdminRequest) (*ssov1.IsAdminResponse, error) { + if req.GetUserId() == emptyValue { + return nil, status.Error(codes.InvalidArgument, "user_id is required") + } + + isAdmin, err := s.auth.IsAdmin(ctx, req.GetUserId()) + if err != nil { + if errors.Is(err, auth.ErrUserNotFound) { + return nil, status.Error(codes.NotFound, "user not found") + } + + return nil, status.Error(codes.Internal, "internal error") + } + + return &ssov1.IsAdminResponse{ + IsAdmin: isAdmin, + }, nil +} diff --git a/internal/lib/jwt/jwt.go b/internal/lib/jwt/jwt.go new file mode 100644 index 0000000..3813236 --- /dev/null +++ b/internal/lib/jwt/jwt.go @@ -0,0 +1,25 @@ +package jwt + +import ( + "sso/internal/domain/models" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +func NewToken(user models.User, app_id int, secret string, duration time.Duration) (string, error) { + token := jwt.New(jwt.SigningMethodHS256) + + claims := token.Claims.(jwt.MapClaims) + claims["uid"] = user.ID + claims["email"] = user.Email + claims["exp"] = time.Now().Add(duration).Unix() + claims["app_id"] = app_id + + tokenString, err := token.SignedString([]byte(secret)) + if err != nil { + return "", err + } + + return tokenString, nil +} diff --git a/internal/lib/logger/sl/sl.go b/internal/lib/logger/sl/sl.go new file mode 100644 index 0000000..ac19308 --- /dev/null +++ b/internal/lib/logger/sl/sl.go @@ -0,0 +1,10 @@ +package sl + +import "log/slog" + +func Err(err error) slog.Attr { + return slog.Attr{ + Key: "error", + Value: slog.StringValue(err.Error()), + } +} diff --git a/internal/lib/logger/slogdiscard/slogdiscard.go b/internal/lib/logger/slogdiscard/slogdiscard.go new file mode 100644 index 0000000..4f19f4e --- /dev/null +++ b/internal/lib/logger/slogdiscard/slogdiscard.go @@ -0,0 +1,40 @@ +package slogdiscard + +/* + Logger for tests with ignoring input +*/ + +import ( + "context" + "log/slog" +) + +func NewDiscardLogger() *slog.Logger { + return slog.New(NewDiscardHandler()) +} + +type DiscardHandler struct{} + +func NewDiscardHandler() *DiscardHandler { + return &DiscardHandler{} +} + +func (h *DiscardHandler) Handle(_ context.Context, _ slog.Record) error { + // Просто игнорируем запись журнала + return nil +} + +func (h *DiscardHandler) WithAttrs(_ []slog.Attr) slog.Handler { + // Возвращает тот же обработчик, так как нет атрибутов для сохранения + return h +} + +func (h *DiscardHandler) WithGroup(_ string) slog.Handler { + // Возвращает тот же обработчик, так как нет группы для сохранения + return h +} + +func (h *DiscardHandler) Enabled(_ context.Context, _ slog.Level) bool { + // Всегда возвращает false, так как запись журнала игнорируется + return false +} diff --git a/internal/lib/logger/slogpretty/slogpretty.go b/internal/lib/logger/slogpretty/slogpretty.go new file mode 100644 index 0000000..000556b --- /dev/null +++ b/internal/lib/logger/slogpretty/slogpretty.go @@ -0,0 +1,99 @@ +package slogpretty + +import ( + "context" + "encoding/json" + "io" + stdLog "log" + + "log/slog" + + "github.com/fatih/color" +) + +type PrettyHandlerOptions struct { + SlogOpts *slog.HandlerOptions +} + +type PrettyHandler struct { + opts PrettyHandlerOptions + slog.Handler + l *stdLog.Logger + attrs []slog.Attr +} + +func (opts PrettyHandlerOptions) NewPrettyHandler( + out io.Writer, +) *PrettyHandler { + h := &PrettyHandler{ + Handler: slog.NewJSONHandler(out, opts.SlogOpts), + l: stdLog.New(out, "", 0), + } + + return h +} + +func (h *PrettyHandler) Handle(_ context.Context, r slog.Record) error { + level := r.Level.String() + ":" + + switch r.Level { + case slog.LevelDebug: + level = color.MagentaString(level) + case slog.LevelInfo: + level = color.BlueString(level) + case slog.LevelWarn: + level = color.YellowString(level) + case slog.LevelError: + level = color.RedString(level) + } + + fields := make(map[string]interface{}, r.NumAttrs()) + + r.Attrs(func(a slog.Attr) bool { + fields[a.Key] = a.Value.Any() + + return true + }) + + for _, a := range h.attrs { + fields[a.Key] = a.Value.Any() + } + + var b []byte + var err error + + if len(fields) > 0 { + b, err = json.MarshalIndent(fields, "", " ") + if err != nil { + return err + } + } + + timeStr := r.Time.Format("[15:05:05.000]") + msg := color.CyanString(r.Message) + + h.l.Println( + timeStr, + level, + msg, + color.WhiteString(string(b)), + ) + + return nil +} + +func (h *PrettyHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return &PrettyHandler{ + Handler: h.Handler, + l: h.l, + attrs: attrs, + } +} + +func (h *PrettyHandler) WithGroup(name string) slog.Handler { + // TODO: implement + return &PrettyHandler{ + Handler: h.Handler.WithGroup(name), + l: h.l, + } +} diff --git a/internal/services/auth/auth.go b/internal/services/auth/auth.go new file mode 100644 index 0000000..a3417b3 --- /dev/null +++ b/internal/services/auth/auth.go @@ -0,0 +1,162 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "log/slog" + "sso/internal/domain/models" + "sso/internal/lib/jwt" + "sso/internal/lib/logger/sl" + "sso/internal/storage" + "time" + + "golang.org/x/crypto/bcrypt" +) + +type Auth struct { + log *slog.Logger + usrSaver UserSaver + usrProvider UserProvider + appProvider AppProvider + tokenTTL time.Duration +} + +type UserSaver interface { + SaveUser(ctx context.Context, email string, passHash []byte) (uid int64, err error) +} + +type UserProvider interface { + User(ctx context.Context, email string) (models.User, error) + IsAdmin(ctx context.Context, userID int64) (bool, error) +} + +type AppProvider interface { + App(ctx context.Context, appID int) (models.App, error) +} + +var ( + ErrInvalidCredentials = errors.New("invalid credentials") + ErrInvalidAppID = errors.New("invalid app id") + ErrUserExists = errors.New("user already exists") + ErrUserNotFound = errors.New("user not found") +) + +// New returns a new instance of the Auth service. +func New(log *slog.Logger, userSaver UserSaver, userProvider UserProvider, appProvider AppProvider, tokenTTL time.Duration) *Auth { + return &Auth{ + log: log, + usrSaver: userSaver, + usrProvider: userProvider, + appProvider: appProvider, + tokenTTL: tokenTTL, + } +} + +// Login checks if user with given credentials exists in the system and returns access token. +// +// If user exists, but password is incorrect, returns error. +// If user doesn't exist, returns error. +func (a *Auth) Login(ctx context.Context, email string, password string, appID int) (string, error) { + const op = "services.auth.Login" + + log := a.log.With( + slog.String("op", op), + slog.String("username", email), + ) + + log.Info("attempting to login user") + + user, err := a.usrProvider.User(ctx, email) + if err != nil { + if errors.Is(err, storage.ErrUserNotFound) { + a.log.Warn("user not found", sl.Err(err)) + return "", fmt.Errorf("%s: %w", op, ErrInvalidCredentials) + } + + a.log.Error("failed to get user", sl.Err(err)) + + return "", fmt.Errorf("%s: %w", op, err) + } + + if err := bcrypt.CompareHashAndPassword(user.PassHash, []byte(password)); err != nil { + a.log.Info("invalid credentials", sl.Err(err)) + return "", fmt.Errorf("%s: %w", op, ErrInvalidCredentials) + } + + app, err := a.appProvider.App(ctx, appID) + if err != nil { + return "", fmt.Errorf("%s: %w", op, err) + } + + log.Info("user logged in successfully") + + token, err := jwt.NewToken(user, app.ID, app.Secret, a.tokenTTL) + if err != nil { + a.log.Error("failed to generate token", sl.Err(err)) + return "", fmt.Errorf("%s: %w", op, err) + } + + return token, nil +} + +// RegisterNewUser registers new user in the system and returns user ID. +// If user with given username already exists, returns error. +func (a *Auth) RegisterNewUser(ctx context.Context, email string, pass string) (int64, error) { + const op = "services.auth.RegisterNewUser" + + log := a.log.With( + slog.String("op", op), + slog.String("email", email), + ) + + log.Info("registering user") + + passHash, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost) + if err != nil { + log.Error("failed to generate password hash", sl.Err(err)) + return 0, fmt.Errorf("%s: %w", op, err) + } + + id, err := a.usrSaver.SaveUser(ctx, email, passHash) + if err != nil { + if errors.Is(err, storage.ErrUserExists) { + log.Warn("user already exists", sl.Err(err)) + + return 0, fmt.Errorf("%s: %w", op, ErrUserExists) + } + log.Error("failed to save user", sl.Err(err)) + return 0, fmt.Errorf("%s: %w", op, err) + } + + log.Info("user registred") + + return id, nil +} + +// IsAdmin checks if user is admin +func (a *Auth) IsAdmin(ctx context.Context, userID int64) (bool, error) { + const op = "services.auth.IsAdmin" + + log := a.log.With( + slog.String("op", op), + slog.Int64("user_id", userID), + ) + + log.Info("checking if user is admin") + + isAdmin, err := a.usrProvider.IsAdmin(ctx, userID) + if err != nil { + if errors.Is(err, storage.ErrAppNotFound) { + log.Warn("app not found", sl.Err(err)) + + return false, fmt.Errorf("%s: %w", op, ErrInvalidAppID) + } + + return false, fmt.Errorf("%s: %w", op, err) + } + + log.Info("checking if user is admin", slog.Bool("is_admin", isAdmin)) + + return isAdmin, nil +} diff --git a/internal/storage/sqlite/sqlite.go b/internal/storage/sqlite/sqlite.go new file mode 100644 index 0000000..9d098cd --- /dev/null +++ b/internal/storage/sqlite/sqlite.go @@ -0,0 +1,118 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "fmt" + "sso/internal/domain/models" + "sso/internal/storage" + + "github.com/mattn/go-sqlite3" +) + +type Storage struct { + db *sql.DB +} + +func New(storagePath string) (*Storage, error) { + const op = "storage.sqlite.New" + + db, err := sql.Open("sqlite3", storagePath) + if err != nil { + return nil, fmt.Errorf("%s: %w", op, err) + } + + return &Storage{db: db}, nil +} + +func (s *Storage) SaveUser(ctx context.Context, email string, passHash []byte) (int64, error) { + const op = "storage.sqlite.SaveUser" + + stmt, err := s.db.Prepare("insert into users(email, pass_hash) values (?, ?)") + if err != nil { + return 0, fmt.Errorf("%s: %w", op, err) + } + + res, err := stmt.ExecContext(ctx, email, passHash) + if err != nil { + var sqliteErr sqlite3.Error + if errors.As(err, &sqliteErr) && sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique { + return 0, fmt.Errorf("%s: %w", op, storage.ErrUserExists) + } + } + + id, err := res.LastInsertId() + if err != nil { + return 0, fmt.Errorf("%s: %w", op, err) + } + + return id, nil +} + +// User returns user by email. +func (s *Storage) User(ctx context.Context, email string) (models.User, error) { + const op = "storage.sqlite.User" + + stmt, err := s.db.Prepare("select id, email, pass_hash from users where email = ?") + if err != nil { + return models.User{}, fmt.Errorf("%s: %w", op, err) + } + + var user models.User + err = stmt.QueryRowContext(ctx, email).Scan(&user.ID, &user.Email, &user.PassHash) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return models.User{}, fmt.Errorf("%s: %w", op, storage.ErrUserNotFound) + } + + return models.User{}, fmt.Errorf("%s: %w", op, err) + } + + return user, nil +} + +func (s *Storage) IsAdmin(ctx context.Context, userID int64) (bool, error) { + const op = "storage.sqlite.IsAdmin" + + stmt, err := s.db.Prepare("select is_admin from users where id = ?") + if err != nil { + return false, fmt.Errorf("%s: %w", op, err) + } + + var is_admin bool + err = stmt.QueryRowContext(ctx, userID).Scan(&is_admin) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return false, fmt.Errorf("%s: %w", op, storage.ErrAppNotFound) + } + + return false, fmt.Errorf("%s: %w", op, err) + } + + return is_admin, nil +} + +// App returns app by id. +func (s *Storage) App(ctx context.Context, id int) (models.App, error) { + const op = "storage.sqlite.App" + + stmt, err := s.db.Prepare("SELECT id, name, secret FROM apps WHERE id = ?") + if err != nil { + return models.App{}, fmt.Errorf("%s: %w", op, err) + } + + row := stmt.QueryRowContext(ctx, id) + + var app models.App + err = row.Scan(&app.ID, &app.Name, &app.Secret) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return models.App{}, fmt.Errorf("%s: %w", op, storage.ErrAppNotFound) + } + + return models.App{}, fmt.Errorf("%s: %w", op, err) + } + + return app, nil +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go new file mode 100644 index 0000000..490c56c --- /dev/null +++ b/internal/storage/storage.go @@ -0,0 +1,9 @@ +package storage + +import "errors" + +var ( + ErrUserExists = errors.New("user already exists") + ErrUserNotFound = errors.New("user not found") + ErrAppNotFound = errors.New("app not found") +) diff --git a/migrations/1_init.down.sql b/migrations/1_init.down.sql new file mode 100644 index 0000000..b00ca75 --- /dev/null +++ b/migrations/1_init.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS users; +DROP TABLE IF EXISTS apps; \ No newline at end of file diff --git a/migrations/1_init.up.sql b/migrations/1_init.up.sql new file mode 100644 index 0000000..22ba82e --- /dev/null +++ b/migrations/1_init.up.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + pass_hash BLOB NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_email ON users (email); + +CREATE TABLE IF NOT EXISTS apps ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + secret TEXT NOT NULL UNIQUE +); \ No newline at end of file diff --git a/migrations/2_add_is_admin_column_to_users_tbl.down.sql b/migrations/2_add_is_admin_column_to_users_tbl.down.sql new file mode 100644 index 0000000..bddaa59 --- /dev/null +++ b/migrations/2_add_is_admin_column_to_users_tbl.down.sql @@ -0,0 +1 @@ +ALTER TABLE users DROP COLUMN is_admin; \ No newline at end of file diff --git a/migrations/2_add_is_admin_column_to_users_tbl.up.sql b/migrations/2_add_is_admin_column_to_users_tbl.up.sql new file mode 100644 index 0000000..b0f9167 --- /dev/null +++ b/migrations/2_add_is_admin_column_to_users_tbl.up.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT FALSE; \ No newline at end of file diff --git a/proto/sso/sso.proto b/proto/sso/sso.proto new file mode 100644 index 0000000..746a607 --- /dev/null +++ b/proto/sso/sso.proto @@ -0,0 +1,38 @@ +syntax = "proto3"; + +package auth; + +option go_package = "yash.sso.v1;ssov1"; + +service Auth { + rpc Register (RegisterRequest) returns (RegisterResponse); + rpc Login (LoginRequest) returns (LoginResponse); + rpc IsAdmin (IsAdminRequest) returns (IsAdminResponse); +} + +message RegisterRequest { + string email = 1; // Email of the user to register + string password = 2; // User ID of the registered user +} + +message RegisterResponse { + int64 user_id = 1; // User ID of the registered user. +} + +message LoginRequest { + string email = 1; // Email of the user to login. + string password = 2; // Password of the user to login. + int32 app_id = 3; // ID of the app to login to. +} + +message LoginResponse { + string token = 1; // Auth token of the logged in user. +} + +message IsAdminRequest { + int64 user_id = 1; // User ID to validate +} + +message IsAdminResponse { + bool is_admin = 1; // Indicates whether the user is an admin. +} diff --git a/tests/auth_register_login_test.go b/tests/auth_register_login_test.go new file mode 100644 index 0000000..6c39935 --- /dev/null +++ b/tests/auth_register_login_test.go @@ -0,0 +1,200 @@ +package tests + +import ( + ssov1 "sso/gen/sso" + "sso/tests/suite" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + emptyAppID = 0 + appID = 1 + appSecret = "test-secret" + + passDefaultLen = 10 +) + +func TestRegisterLogin_Login_HappyPath(t *testing.T) { + ctx, st := suite.New(t) + + email := gofakeit.Email() + pass := randomFakePassword() + + respReg, err := st.AuthClient.Register(ctx, &ssov1.RegisterRequest{ + Email: email, + Password: pass, + }) + require.NoError(t, err) + assert.NotEmpty(t, respReg.GetUserId()) + + respLogin, err := st.AuthClient.Login(ctx, &ssov1.LoginRequest{ + Email: email, + Password: pass, + AppId: appID, + }) + require.NoError(t, err) + + loginTime := time.Now() + + token := respLogin.GetToken() + require.NotEmpty(t, token) + + tokenParsed, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) { + return []byte(appSecret), err + }) + require.NoError(t, err) + + claims, ok := tokenParsed.Claims.(jwt.MapClaims) + assert.True(t, ok) + + assert.Equal(t, respReg.GetUserId(), int64(claims["uid"].(float64))) + assert.Equal(t, email, claims["email"].(string)) + assert.Equal(t, appID, int(claims["app_id"].(float64))) + + const deltaSeconds = 1 + + assert.InDelta(t, loginTime.Add(st.Cfg.TokenTTL).Unix(), claims["exp"].(float64), deltaSeconds) +} + +func TestRegisterLogin_DuplicatedRegistration(t *testing.T) { + ctx, st := suite.New(t) + + email := gofakeit.Email() + pass := randomFakePassword() + + reqFirstReg, err := st.AuthClient.Register(ctx, &ssov1.RegisterRequest{ + Email: email, + Password: pass, + }) + require.NoError(t, err) + + assert.NotEmpty(t, reqFirstReg.GetUserId()) + + reqSecondReg, err := st.AuthClient.Register(ctx, &ssov1.RegisterRequest{ + Email: email, + Password: pass, + }) + + require.Error(t, err) + + assert.Empty(t, reqSecondReg.GetUserId()) + + assert.ErrorContains(t, err, "user already exists") +} + +func randomFakePassword() string { + return gofakeit.Password(true, true, true, true, false, passDefaultLen) +} + +func TestRegister_FailCases(t *testing.T) { + ctx, st := suite.New(t) + + tests := []struct { + name string + email string + password string + expectedErr string + }{ + { + name: "Register with empty password", + email: gofakeit.Email(), + password: "", + expectedErr: "password is required", + }, + { + name: "Register with empty email", + email: "", + password: randomFakePassword(), + expectedErr: "email is required", + }, + { + name: "Register with both empty", + email: "", + password: "", + expectedErr: "email is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := st.AuthClient.Register(ctx, &ssov1.RegisterRequest{ + Email: tt.email, + Password: tt.password, + }) + require.Error(t, err) + require.Contains(t, err.Error(), tt.expectedErr) + }) + } +} + +func TestLogin_FailCases(t *testing.T) { + ctx, st := suite.New(t) + + tests := []struct { + name string + email string + password string + appID int32 + expectedErr string + }{ + { + name: "Login with Empty Password", + email: gofakeit.Email(), + password: "", + appID: appID, + expectedErr: "password is required", + }, + { + name: "Login with Empty Email", + email: "", + password: randomFakePassword(), + appID: appID, + expectedErr: "email is required", + }, + { + name: "Login with Both Empty Email and Password", + email: "", + password: "", + appID: appID, + expectedErr: "email is required", + }, + { + name: "Login with Non-Matching Password", + email: gofakeit.Email(), + password: randomFakePassword(), + appID: appID, + expectedErr: "invalid credentials", + }, + { + name: "Login without AppID", + email: gofakeit.Email(), + password: randomFakePassword(), + appID: emptyAppID, + expectedErr: "app_id is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := st.AuthClient.Register(ctx, &ssov1.RegisterRequest{ + Email: gofakeit.Email(), + Password: randomFakePassword(), + }) + require.NoError(t, err) + + _, err = st.AuthClient.Login(ctx, &ssov1.LoginRequest{ + Email: tt.email, + Password: tt.password, + AppId: tt.appID, + }) + require.Error(t, err) + require.Contains(t, err.Error(), tt.expectedErr) + }) + } +} diff --git a/tests/migrations/1_init_apps.up.sql b/tests/migrations/1_init_apps.up.sql new file mode 100644 index 0000000..b2f6872 --- /dev/null +++ b/tests/migrations/1_init_apps.up.sql @@ -0,0 +1 @@ +insert into apps (id, name, secret) values (1, 'test', 'test-secret') ON CONFLICT DO NOTHING; \ No newline at end of file diff --git a/tests/suite/suite.go b/tests/suite/suite.go new file mode 100644 index 0000000..b316607 --- /dev/null +++ b/tests/suite/suite.go @@ -0,0 +1,53 @@ +package suite + +import ( + "context" + "net" + ssov1 "sso/gen/sso" + "sso/internal/config" + "strconv" + "testing" + + "google.golang.org/grpc" +) + +type Suite struct { + *testing.T + Cfg *config.Config + AuthClient ssov1.AuthClient +} + +const ( + grpcHost = "localhost" +) + +func New(t *testing.T) (context.Context, *Suite) { + t.Helper() + t.Parallel() + + cfg := config.MustLoadByPath("../config/local.yaml") + + ctx, cancelCtx := context.WithTimeout(context.Background(), cfg.GRPC.Timeout) + t.Cleanup(func() { + t.Helper() + cancelCtx() + }) + + cc, err := grpc.DialContext(context.Background(), grpcAddress(cfg), + // grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithInsecure(), + ) + if err != nil { + t.Fatalf("grpc server connection failed: %v", err) + } + + return ctx, &Suite{ + T: t, + Cfg: cfg, + AuthClient: ssov1.NewAuthClient(cc), + } +} + +func grpcAddress(cfg *config.Config) string { + return net.JoinHostPort(grpcHost, strconv.Itoa(cfg.GRPC.Port)) +}