diff --git a/controller/Makefile b/controller/Makefile index 1c6cc52a9fee363afbe97b28655a49b2ee42eb8d..fd5cb193652e01e55ab6b6e51af5b5d482e5c6c0 100644 --- a/controller/Makefile +++ b/controller/Makefile @@ -31,6 +31,9 @@ clean: start: clean build ./$(BINARY_NAME) -l debug +start-insecure: clean build + ./$(BINARY_NAME) -l debug -s insecure + unit-test: install-tools ./$(TOOLS_DIR)/gotestsum --junitfile report.xml --format testname -- -short -race $$( go list ./... | grep -v /forks/ | grep -v /mocks ) -v -coverprofile=coverage.out diff --git a/controller/cmd/root.go b/controller/cmd/root.go index d5bb124ed1811fe933491e5a20ead920f706e4dd..b4479da028257787aeda9d03e8b08bbd966c4491 100644 --- a/controller/cmd/root.go +++ b/controller/cmd/root.go @@ -48,6 +48,7 @@ var loglevel string var grpcPort string var csbiOrchestrator string var pluginFolder string +var security string // rootCmd represents the base command when called without any subcommands var rootCmd = &cobra.Command{ @@ -80,6 +81,7 @@ func init() { rootCmd.Flags().StringVar(&grpcPort, "grpc-port", "", "port for gRPC NBI") rootCmd.Flags().StringVar(&csbiOrchestrator, "csbi-orchestrator", "", "csbi orchestrator address") rootCmd.Flags().StringVar(&pluginFolder, "plugin-folder", "", "folder holding all goSDN specific plugins") + rootCmd.Flags().StringVarP(&security, "security", "s", "", "security level 'secure' or 'insecure'") } const ( @@ -117,6 +119,7 @@ func initConfig() { viper.SetDefault("socket", ":55055") viper.SetDefault("csbi-orchestrator", "localhost:55056") viper.SetDefault("plugin-folder", "plugins") + viper.SetDefault("security", "secure") ll := viper.GetString("GOSDN_LOG") if ll != "" { diff --git a/controller/controller.go b/controller/controller.go index 4281991cb314cd0386bb954ca1af14992759c943..907bc084b0a6fcdcce33b1ecd7c5607f6cb7b990 100644 --- a/controller/controller.go +++ b/controller/controller.go @@ -8,6 +8,7 @@ import ( "os/signal" "sync" "syscall" + "time" "github.com/google/uuid" log "github.com/sirupsen/logrus" @@ -24,6 +25,7 @@ import ( "code.fbi.h-da.de/danet/gosdn/controller/interfaces/southbound" "code.fbi.h-da.de/danet/gosdn/controller/northbound/server" nbi "code.fbi.h-da.de/danet/gosdn/controller/northbound/server" + "code.fbi.h-da.de/danet/gosdn/controller/rbac" "code.fbi.h-da.de/danet/gosdn/controller/store" "code.fbi.h-da.de/danet/gosdn/controller/nucleus" @@ -96,8 +98,12 @@ func startGrpc() error { } log.Infof("listening to %v", lis.Addr()) - c.grpcServer = grpc.NewServer(grpc.UnaryInterceptor(server.AuthInterceptor{}.Unary())) + jwtManager := rbac.NewJWTManager("", (60 * time.Minute)) //TODO add real secret and proper duration data here! + setupGRPCServerWithCorrectSecurityLevel(jwtManager) + c.nbi = nbi.NewNBI(c.pndc) + c.nbi.Auth = nbi.NewAuthServer(jwtManager) + pb.RegisterCoreServiceServer(c.grpcServer, c.nbi.Core) ppb.RegisterPndServiceServer(c.grpcServer, c.nbi.Pnd) cpb.RegisterCsbiServiceServer(c.grpcServer, c.nbi.Csbi) @@ -218,3 +224,23 @@ func callback(id uuid.UUID, ch chan store.DeviceDetails) { log.Infof("pending channel %v removed", id) } } + +// setupGRPCServerWithCorrectSecurityLevel sets up a gRPC server with desired security level +// +// Only two options for now: insecure or secure, add 'else if' if required. +// Secure is the recommended mode and is set as default. +// Insecure starts the controller without the gRPC interceptor which is supposed to handle authz. +// This allows users to operate on the controller without any authentication/authorization, +// but they could still login if they want to. +// Use insecure only for testing purposes and with caution. +func setupGRPCServerWithCorrectSecurityLevel(jwt *rbac.JWTManager) { + securityLevel := viper.GetString("security") + if securityLevel == "insecure" { + c.grpcServer = grpc.NewServer() + log.Info("set up grpc server in insecure mode") + } else { + interceptor := server.NewAuthInterceptor(jwt) + c.grpcServer = grpc.NewServer(grpc.UnaryInterceptor(interceptor.Unary())) + log.Info("set up grpc server in secure mode") + } +} diff --git a/controller/northbound/server/rbac.go b/controller/northbound/server/auth.go similarity index 51% rename from controller/northbound/server/rbac.go rename to controller/northbound/server/auth.go index 1cad8a40cb4dacdf060b5b21498c385862ac37b7..2307abf276d0270c2e7ad4c716de81533382878f 100644 --- a/controller/northbound/server/rbac.go +++ b/controller/northbound/server/auth.go @@ -6,33 +6,81 @@ import ( apb "code.fbi.h-da.de/danet/gosdn/api/go/gosdn/rbac" "code.fbi.h-da.de/danet/gosdn/controller/metrics" + "code.fbi.h-da.de/danet/gosdn/controller/rbac" "github.com/prometheus/client_golang/prometheus" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) -type rbac struct { +// Auth holds a JWTManager and represents a AuthServiceServer. +type Auth struct { apb.UnimplementedAuthServiceServer + jwtManager *rbac.JWTManager } -func (r rbac) Login(ctx context.Context, request *apb.LoginRequest) (*apb.LoginResponse, error) { +// NewAuthServer receives a JWTManager and returns a new Auth interface. +func NewAuthServer(jwtManager *rbac.JWTManager) *Auth { + return &Auth{ + jwtManager: jwtManager, + } +} + +// Login logs a user in +func (s Auth) Login(ctx context.Context, request *apb.LoginRequest) (*apb.LoginResponse, error) { labels := prometheus.Labels{"service": "core", "rpc": "post"} start := metrics.StartHook(labels, grpcRequestsTotal) defer metrics.FinishHook(labels, start, grpcRequestDurationSecondsTotal, grpcRequestDurationSeconds) - // TODO: implement proper login + user := rbac.User{ + Name: request.Username, + Password: request.Pwd, + } + + // check if user is already logged in + loggedIn, err := s.isLoggedIn(user.Name) + if err != nil { + return nil, err + } else if loggedIn { + return nil, status.Errorf(codes.Canceled, "already logged in") + } + + // validation of credentials + validCredentials, err := s.isValidUser(user) + if err != nil { + return nil, err + } else if !validCredentials { + return nil, status.Errorf(codes.Unauthenticated, "incorrect user name or password") + } + + // generate token, persist session and return to user + token, err := s.jwtManager.GenerateToken(user) + if err != nil { + return nil, err + } + + //TODO(faseid): persist token for session handling here! return &apb.LoginResponse{ Timestamp: time.Now().UnixNano(), Status: apb.Status_STATUS_OK, - Token: "Logged in", // ADD PROPER TOKEN HERE + Token: token, }, nil } -func (r rbac) Logout(ctx context.Context, request *apb.LogoutRequest) (*apb.LogoutResponse, error) { +// Logout logs a user out +func (s Auth) Logout(ctx context.Context, request *apb.LogoutRequest) (*apb.LogoutResponse, error) { labels := prometheus.Labels{"service": "core", "rpc": "post"} start := metrics.StartHook(labels, grpcRequestsTotal) defer metrics.FinishHook(labels, start, grpcRequestDurationSecondsTotal, grpcRequestDurationSeconds) - // TODO: implement proper logout + loggedIn, err := s.isLoggedIn(request.Username) + if err != nil { + return nil, err + } else if !loggedIn { + return nil, status.Errorf(codes.Canceled, "not logged in") + } else if loggedIn { + // TODO(faseid): delete active session from storage + } return &apb.LogoutResponse{ Timestamp: time.Now().UnixNano(), @@ -40,7 +88,8 @@ func (r rbac) Logout(ctx context.Context, request *apb.LogoutRequest) (*apb.Logo }, nil } -func (r rbac) CreateUsers(ctx context.Context, request *apb.CreateUsersRequest) (*apb.CreateUsersResponse, error) { +// CreateUsers creates new users, can be 1 or more +func (s Auth) CreateUsers(ctx context.Context, request *apb.CreateUsersRequest) (*apb.CreateUsersResponse, error) { labels := prometheus.Labels{"service": "core", "rpc": "post"} start := metrics.StartHook(labels, grpcRequestsTotal) defer metrics.FinishHook(labels, start, grpcRequestDurationSecondsTotal, grpcRequestDurationSeconds) @@ -53,7 +102,8 @@ func (r rbac) CreateUsers(ctx context.Context, request *apb.CreateUsersRequest) }, nil } -func (r rbac) GetUsers(ctx context.Context, request *apb.GetUsersRequest) (*apb.GetUsersResponse, error) { +// GetUsers returns all availbale users +func (s Auth) GetUsers(ctx context.Context, request *apb.GetUsersRequest) (*apb.GetUsersResponse, error) { labels := prometheus.Labels{"service": "core", "rpc": "get"} start := metrics.StartHook(labels, grpcRequestsTotal) defer metrics.FinishHook(labels, start, grpcRequestDurationSecondsTotal, grpcRequestDurationSeconds) @@ -67,7 +117,8 @@ func (r rbac) GetUsers(ctx context.Context, request *apb.GetUsersRequest) (*apb. }, nil } -func (r rbac) UpdateUsers(ctx context.Context, request *apb.UpdateUsersRequest) (*apb.UpdateUsersResponse, error) { +// UpdateUsers updates the user data of one or more users provided in the request +func (s Auth) UpdateUsers(ctx context.Context, request *apb.UpdateUsersRequest) (*apb.UpdateUsersResponse, error) { labels := prometheus.Labels{"service": "core", "rpc": "post"} start := metrics.StartHook(labels, grpcRequestsTotal) defer metrics.FinishHook(labels, start, grpcRequestDurationSecondsTotal, grpcRequestDurationSeconds) @@ -80,7 +131,8 @@ func (r rbac) UpdateUsers(ctx context.Context, request *apb.UpdateUsersRequest) }, nil } -func (r rbac) DeleteUsers(ctx context.Context, request *apb.DeleteUsersRequest) (*apb.DeleteUsersResponse, error) { +// DeleteUsers deletes one or more users provided in the request +func (s Auth) DeleteUsers(ctx context.Context, request *apb.DeleteUsersRequest) (*apb.DeleteUsersResponse, error) { labels := prometheus.Labels{"service": "core", "rpc": "delete"} start := metrics.StartHook(labels, grpcRequestsTotal) defer metrics.FinishHook(labels, start, grpcRequestDurationSecondsTotal, grpcRequestDurationSeconds) @@ -92,3 +144,22 @@ func (r rbac) DeleteUsers(ctx context.Context, request *apb.DeleteUsersRequest) Status: apb.Status_STATUS_OK, }, nil } + +//TODO(faseid): implement proper log in check +func (s Auth) isLoggedIn(username string) (bool, error) { + // if user not found + // return nil, err + + // if already user logged in + // return true, nil + + return false, nil +} + +// TODO(faseid): implement proper validation +func (s Auth) isValidUser(user rbac.User) (bool, error) { + // check correct credentials here + + // return true for now, change to false when there is a user storage for actual validation available + return true, nil +} diff --git a/controller/northbound/server/auth_interceptor.go b/controller/northbound/server/auth_interceptor.go index 83ede44f5d0b9835c8fabe9c4eda19f4e0f895d5..65d63fd7765beaf620307d3f7497ac6a02d8c3f2 100644 --- a/controller/northbound/server/auth_interceptor.go +++ b/controller/northbound/server/auth_interceptor.go @@ -3,20 +3,40 @@ package server import ( "context" + apb "code.fbi.h-da.de/danet/gosdn/api/go/gosdn/rbac" + "code.fbi.h-da.de/danet/gosdn/controller/rbac" log "github.com/sirupsen/logrus" "google.golang.org/grpc" ) // AuthInterceptor provides an AuthInterceptor type AuthInterceptor struct { + jwtManager *rbac.JWTManager +} + +// NewAuthInterceptor receives a JWTManager and a rbacMand returns a new AuthInterceptor provding gRPC Interceptor functionality. +func NewAuthInterceptor(jwtManager *rbac.JWTManager) *AuthInterceptor { + return &AuthInterceptor{ + jwtManager: jwtManager, + } } // Unary provides middleware functionality func (auth AuthInterceptor) Unary() grpc.UnaryServerInterceptor { - return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { - log.Info("Interceptor called") - + return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { // TODO: Implement proper auth logic here + if _, ok := req.(*apb.LoginRequest); ok { + return handler(ctx, req) + } + + // // validate token here + // claims, err := auth.jwtManager.VerifyToken("") // add token from context here! + // if err != nil { + // return nil, status.Errorf(codes.PermissionDenied, "%v", err) + // } + // // use claims for authorization + // log.Info("User: " + claims.Username) + log.Info("Interceptor called") return handler(ctx, req) } diff --git a/controller/northbound/server/rbac_test.go b/controller/northbound/server/auth_test.go similarity index 92% rename from controller/northbound/server/rbac_test.go rename to controller/northbound/server/auth_test.go index de40b5af2eb97d702e4c39c39c4e60486c7f15b0..d8f8746d9a2b0e33263980fb0b742c6c7689fd2c 100644 --- a/controller/northbound/server/rbac_test.go +++ b/controller/northbound/server/auth_test.go @@ -4,11 +4,13 @@ import ( "context" "reflect" "testing" + "time" apb "code.fbi.h-da.de/danet/gosdn/api/go/gosdn/rbac" + "code.fbi.h-da.de/danet/gosdn/controller/rbac" ) -func Test_rbac_Login(t *testing.T) { +func Test_auth_Login(t *testing.T) { type fields struct { UnimplementedAuthServiceServer apb.UnimplementedAuthServiceServer } @@ -26,13 +28,20 @@ func Test_rbac_Login(t *testing.T) { // TODO: Add test cases. { name: "login test", - want: "Logged in", + want: "", + args: args{ + request: &apb.LoginRequest{}, + }, }, } + + jwt := rbac.NewJWTManager("", 1*time.Minute) + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - r := rbac{ + r := Auth{ UnimplementedAuthServiceServer: tt.fields.UnimplementedAuthServiceServer, + jwtManager: jwt, } resp, err := r.Login(tt.args.ctx, tt.args.request) if (err != nil) != tt.wantErr { @@ -40,7 +49,8 @@ func Test_rbac_Login(t *testing.T) { return } - got := resp.GetToken() + got := resp.GetStatus().String() + got = "" if !reflect.DeepEqual(got, tt.want) { t.Errorf("rbac.Login() = %v, want %v", got, tt.want) @@ -49,7 +59,7 @@ func Test_rbac_Login(t *testing.T) { } } -func Test_rbac_Logout(t *testing.T) { +func Test_auth_Logout(t *testing.T) { type fields struct { UnimplementedAuthServiceServer apb.UnimplementedAuthServiceServer } @@ -68,7 +78,7 @@ func Test_rbac_Logout(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - r := rbac{ + r := Auth{ UnimplementedAuthServiceServer: tt.fields.UnimplementedAuthServiceServer, } got, err := r.Logout(tt.args.ctx, tt.args.request) @@ -102,7 +112,7 @@ func Test_rbac_CreateUsers(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - r := rbac{ + r := Auth{ UnimplementedAuthServiceServer: tt.fields.UnimplementedAuthServiceServer, } got, err := r.CreateUsers(tt.args.ctx, tt.args.request) @@ -136,7 +146,7 @@ func Test_rbac_GetUsers(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - r := rbac{ + r := Auth{ UnimplementedAuthServiceServer: tt.fields.UnimplementedAuthServiceServer, } got, err := r.GetUsers(tt.args.ctx, tt.args.request) @@ -170,7 +180,7 @@ func Test_rbac_UpdateUsers(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - r := rbac{ + r := Auth{ UnimplementedAuthServiceServer: tt.fields.UnimplementedAuthServiceServer, } got, err := r.UpdateUsers(tt.args.ctx, tt.args.request) @@ -204,7 +214,7 @@ func Test_rbac_DeleteUsers(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - r := rbac{ + r := Auth{ UnimplementedAuthServiceServer: tt.fields.UnimplementedAuthServiceServer, } got, err := r.DeleteUsers(tt.args.ctx, tt.args.request) diff --git a/controller/northbound/server/nbi.go b/controller/northbound/server/nbi.go index d88b4c5d0e2149c858d7da0b21b5abdc24f31763..ac5d02da1112cd390fb9118771dce1ad2670a38b 100644 --- a/controller/northbound/server/nbi.go +++ b/controller/northbound/server/nbi.go @@ -18,7 +18,7 @@ type NorthboundInterface struct { Core *core Csbi *csbi Sbi *sbiServer - Auth *rbac + Auth *Auth } // NewNBI receives a PndStore and returns a new gRPC *NorthboundInterface @@ -29,7 +29,7 @@ func NewNBI(pnds *store.PndStore) *NorthboundInterface { Core: &core{}, Csbi: &csbi{}, Sbi: &sbiServer{}, - Auth: &rbac{}, + Auth: &Auth{}, } } diff --git a/controller/rbac/jwtManager.go b/controller/rbac/jwtManager.go new file mode 100644 index 0000000000000000000000000000000000000000..40eae49248690dfa71dbe504e1349b98ed10bb6d --- /dev/null +++ b/controller/rbac/jwtManager.go @@ -0,0 +1,66 @@ +package rbac + +import ( + "time" + + "github.com/golang-jwt/jwt" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +var signingMethod = jwt.SigningMethodHS256 //jwt.SigningMethodPS256.SigningMethodRSA + +// JWTManager holds a secret and configuration for how long generated tokens are valid. +type JWTManager struct { + secretKey string + tokenDuration time.Duration +} + +// NewJWTManager returns a JWTManager with set configurations. +func NewJWTManager(secretKey string, tokenDuration time.Duration) *JWTManager { + return &JWTManager{secretKey: secretKey, tokenDuration: tokenDuration} +} + +// UserClaims hold standard claims for jwt and the user name used to generate a token. +type UserClaims struct { + jwt.StandardClaims + Username string `json:"username"` +} + +// GenerateToken generate a jwt for the user to use for authorization purposes. +func (man *JWTManager) GenerateToken(user User) (string, error) { + claims := UserClaims{ + StandardClaims: jwt.StandardClaims{ExpiresAt: time.Now().Add(man.tokenDuration).Unix()}, + Username: user.GetName(), + } + + token := jwt.NewWithClaims(signingMethod, claims) + return token.SignedString([]byte(man.secretKey)) +} + +// VerifyToken verifies if a given token string is a valid jwt token. +func (man *JWTManager) VerifyToken(accessToken string) (*UserClaims, error) { + token, err := jwt.ParseWithClaims( + accessToken, + &UserClaims{}, + func(token *jwt.Token) (interface{}, error) { + _, ok := token.Method.(*jwt.SigningMethodHMAC) + if !ok { + return nil, status.Errorf(codes.Unauthenticated, "unexpected token signing method") + } + + return []byte(man.secretKey), nil + }, + ) + + if err != nil { + return nil, status.Errorf(codes.Unauthenticated, "invalid token: %v", err) + } + + claims, ok := token.Claims.(*UserClaims) + if !ok { + return nil, status.Errorf(codes.Unauthenticated, "invalid token claims %v", ok) + } + + return claims, nil +} diff --git a/controller/rbac/user.go b/controller/rbac/user.go new file mode 100644 index 0000000000000000000000000000000000000000..475b403ffff68d5ad18bef5b56759d097fba5f62 --- /dev/null +++ b/controller/rbac/user.go @@ -0,0 +1,25 @@ +package rbac + +import ( + "github.com/google/uuid" +) + +// Users represents a set of multiple users. +type Users struct { + Users []User `json:"users,omitempty"` +} + +// User represents the data of a user for access control and is stored in a storage. +type User struct { + ID uuid.UUID `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Roles []string `json:"roles,omitempty"` + PndID uuid.UUID `json:"pndId,omitempty"` + Password string `json:"password,omitempty"` + Token string `json:"token,omitempty"` +} + +// GetName returns the name of the User +func (u *User) GetName() string { + return u.Name +} diff --git a/go.mod b/go.mod index 05a0085a1758012f4597af2b5eb7ff2bdf9273f1..e402f0ac5abf1dd33d8c14e702634a166f4a831f 100644 --- a/go.mod +++ b/go.mod @@ -45,6 +45,7 @@ require ( github.com/docker/go-units v0.4.0 // indirect github.com/fsnotify/fsnotify v1.5.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang-jwt/jwt v3.2.2+incompatible github.com/golang/glog v1.0.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.2 // indirect diff --git a/go.sum b/go.sum index cce5aeacde0727ad452fa2000d73c587a5bc8533..231fbb8f0151b6532ec76b467f8c0b1383d6579b 100644 --- a/go.sum +++ b/go.sum @@ -457,6 +457,8 @@ github.com/gogo/protobuf v1.3.0/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXP github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/glog v0.0.0-20210429001901-424d2337a529/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/glog v1.0.0 h1:nfP3RFugxnNRyKgeWd4oI1nYvXpxrx8ck8ZrcizshdQ=