This commit is contained in:
yyasha 2023-12-17 13:17:55 +03:00
commit c0cfe20445
30 changed files with 1963 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
*.db

6
Makefile Normal file
View File

@ -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

44
cmd/migrator/main.go Normal file
View File

@ -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")
}

77
cmd/sso/main.go Normal file
View File

@ -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)
}

6
config/local.yaml Normal file
View File

@ -0,0 +1,6 @@
env: "local" # dev, prod
storage_path: "./storage/sso.db"
token_ttl: 1h
grpc:
port: 44044
timeout: 10m

501
gen/sso/sso.pb.go Normal file
View File

@ -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
}

183
gen/sso/sso_grpc.pb.go Normal file
View File

@ -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",
}

36
go.mod Normal file
View File

@ -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
)

76
go.sum Normal file
View File

@ -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=

28
internal/app/app.go Normal file
View File

@ -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,
}
}

63
internal/app/grpc/app.go Normal file
View File

@ -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()
}

55
internal/config/config.go Normal file
View File

@ -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
}

View File

@ -0,0 +1,7 @@
package models
type App struct {
ID int
Name string
Secret string
}

View File

@ -0,0 +1,7 @@
package models
type User struct {
ID int64
Email string
PassHash []byte
}

View File

@ -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
}

25
internal/lib/jwt/jwt.go Normal file
View File

@ -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
}

View File

@ -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()),
}
}

View File

@ -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
}

View File

@ -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,
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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")
)

View File

@ -0,0 +1,2 @@
DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS apps;

12
migrations/1_init.up.sql Normal file
View File

@ -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
);

View File

@ -0,0 +1 @@
ALTER TABLE users DROP COLUMN is_admin;

View File

@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT FALSE;

38
proto/sso/sso.proto Normal file
View File

@ -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.
}

View File

@ -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)
})
}
}

View File

@ -0,0 +1 @@
insert into apps (id, name, secret) values (1, 'test', 'test-secret') ON CONFLICT DO NOTHING;

53
tests/suite/suite.go Normal file
View File

@ -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))
}