Skip to content

Commit

Permalink
Implement API auth
Browse files Browse the repository at this point in the history
Implement API authentication and authorization.

- Define IAuthorizer interface that can be used to obtain a gin
  middleware handler that performs authorisation.
- Add a mechanism to obtain an IAuthorizer for a particular role base on
  Veraison configuration.
- Implement "passthrough" authorizer that duplicates existing behavior
  (no auth).
- Implement "basic" authorizer that does not rely on an external
  authorization server.
- Implement "keycloak" authorizer that uses Keycloak service for
  authentication.
- Update provisioning service to authorize based on "provisioner" role.
- Update management service to authorize based on "manager" role.
- Add the previously missing README for the management service.

Signed-off-by: Sergei Trofimov <[email protected]>
  • Loading branch information
setrofim committed Aug 31, 2023
1 parent 8b26395 commit 9e84803
Show file tree
Hide file tree
Showing 39 changed files with 2,681 additions and 28 deletions.
133 changes: 133 additions & 0 deletions auth/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
## Overview

This directory implements authentication and authorization for Veraison API.
Authentication can be performed using the Basic HTTP scheme (with the `basic`
backend), or using a Bearer token (with the `keycloak` backend). Once an API
user is authenticated, authorization is
[role-based](https://en.wikipedia.org/wiki/Role-based_access_control). See
documentation for specific services for which role(s) are needed to access
their API.


## Configuration

- `backend`: specifies which auth backend will be used by the service. The
valid options are:

- `passthrough`: a backend that does not perform any authentication, allowing
all requests.
- `none`: alias for `passthrough`.
- `basic`: Uses the Basic HTTP authentication scheme. See
[RFC7617](https://datatracker.ietf.org/doc/html/rfc7617) for details. This
is not intended for production.
- `keycloak`: Uses OpenID Connect protocol as implemented by the Keycloak
authentication server.

See below for details of how to configure individual backends.

### Passthrough

No additional configuration is required. `passthrough` will allow all requests.
This is the default if `auth` configuration is not provided.

### Basic

- `users`: this is a mapping of user names onto their bcrypt password hashes
and roles. The key of the mapping is the user name, the value is a futher
mapping for the details with the following fields:

- `password`: the bcrypt hash of the user's password.
- `roles`: either a single role or a list of roles associated with the
user. API authrization will be performed based on the user's roles.

bcrypt hashes can be generated on the command line using `mkpasswd` utility,
e.g.:

```bash
mkpasswd -m bcrypt --stdin <<< Passw0rd!
```

For example:

```yaml
auth:
backend: basic
users:
user1:
password: "$2b$05$XgVBveh6QPrRHXI.8S/J9uobBR7Wv9z4CL8yACHEmKIQmYSSyKAqC" # Passw0rd!
roles: provisioner
user2:
password: "$2b$05$x5fvAV5WPkX0KXzqf5FMKODz0uyi2ioew1lOrF2Czp2aNH1LQmhki" # @s3cr3t
roles: [manager, provisioner]
```
### Keycloak
- `host` (optional): host name of the Keycloak service. Defaults to
`localhost`.
- `port` (optional): the port on which the Keycloak service is listening.
Defaults to `8080`.
- `realm` (optional): the Keycloak realm used by Veraison. A realm contains the
configuration for clients, users, roles, etc. It is roughly analogous to a
"tenant id". Defaults to `veraison`.

For example:

```yaml
auth:
backend: keycloak
host: keycloak.example.com
port: 11111
realm: veraison
```

## Usage

```go
"github.com/gin-gonic/gin"
"github.com/veraison/services/auth"
"github.com/veraison/services/config"
"github.com/veraison/services/log"
func main() {
// Load authroizer config.
v, err := config.ReadRawConfig(*config.File, false)
if err != nil {
log.Fatalf("Could not read config: %v", err)
}
subs, err := config.GetSubs(v, "*auth")
if err != nil {
log.Fatalf("Could not parse config: %v", err)
}
// Create new authorizer based on the loaded config.
authorizer, err := auth.NewAuthorizer(subs["auth"], log.Named("auth"))
if err != nil {
log.Fatalf("could not init authorizer: %v", err)
}
// Ensure the authorizer is terminated properly on exit
defer func() {
err := authorizer.Close()
if err != nil {
log.Errorf("Could not close authorizer: %v", err)
}
}()
// Use the authorizer to set a middleware handler in the appropriate gin
// router, with an appropriate role.
router := gin.Default()
router.Use(authorizer.GetGinHandler(auth.ManagerRole))
// Set up route handling here
// ...
// ...
// Run the service.
if err := router.Run("0.0.0.0:80"); err != nil {
log.Errorf("Gin engine failed: %v", err)
}
}
```

47 changes: 47 additions & 0 deletions auth/authorizer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Copyright 2023 Contributors to the Veraison project.
// SPDX-License-Identifier: Apache-2.0

package auth

import (
"fmt"

"github.com/spf13/viper"
"github.com/veraison/services/config"
"go.uber.org/zap"
)

type cfg struct {
Backend string `mapstructure:"backend,omitempty"`
BackendConfigs map[string]interface{} `mapstructure:",remain"`
}

func NewAuthorizer(v *viper.Viper, logger *zap.SugaredLogger) (IAuthorizer, error) {
cfg := cfg{
Backend: "passthrough",
}

loader := config.NewLoader(&cfg)
if err := loader.LoadFromViper(v); err != nil {
return nil, err
}

var a IAuthorizer

switch cfg.Backend {
case "none", "passthrough":
a = &PassthroughAuthorizer{}
case "basic":
a = &BasicAuthorizer{}
case "keycloak":
a = &KeycloakAuthorizer{}
default:
return nil, fmt.Errorf("backend %q is not supported", cfg.Backend)
}

if err := a.Init(v, logger); err != nil {
return nil, err
}

return a, nil
}
149 changes: 149 additions & 0 deletions auth/basic.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
// Copyright 2023 Contributors to the Veraison project.
// SPDX-License-Identifier: Apache-2.0

package auth

import (
"errors"
"fmt"
"net/http"

"github.com/gin-gonic/gin"
"github.com/spf13/viper"
"github.com/veraison/services/log"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
)

type basicAuthUser struct {
Password string `mapstructure:"password"`
Roles []string `mapstructure:"roles"`
}

func newBasicAuthUser(m map[string]interface{}) (*basicAuthUser, error) {
var newUser basicAuthUser

passRaw, ok := m["password"]
if !ok {
return nil, errors.New("password not set")
}

switch t := passRaw.(type) {
case string:
newUser.Password = t
default:
return nil, fmt.Errorf("invalid password: expected string found %T", t)
}

rolesRaw, ok := m["roles"]
if ok {
switch t := rolesRaw.(type) {
case []string:
newUser.Roles = t
case string:
newUser.Roles = make([]string, 1)
newUser.Roles[0] = t
default:
return nil, fmt.Errorf(
"invalid roles: expected []string or string, found %T",
t,
)
}
} else {
newUser.Roles = make([]string, 0)
}

return &newUser, nil
}

type BasicAuthorizer struct {
logger *zap.SugaredLogger
users map[string]*basicAuthUser
}

func (o *BasicAuthorizer) Init(v *viper.Viper, logger *zap.SugaredLogger) error {
if logger == nil {
return errors.New("nil logger")
}
o.logger = logger

o.users = make(map[string]*basicAuthUser)
if rawUsers := v.GetStringMap("users"); rawUsers != nil {
for name, rawUser := range rawUsers {
switch t := rawUser.(type) {
case map[string]interface{}:
newUser, err := newBasicAuthUser(t)
if err != nil {
return fmt.Errorf("invalid user %q: %w", name, err)

}
o.logger.Debugw("registered user",
"user", name,
"password", newUser.Password,
"roles", newUser.Roles,
)
o.users[name] = newUser
default:
return fmt.Errorf(
"invalid user %q: expected map[string]interface{}, got %T",
name, t,
)
}
}
}

return nil
}

func (o *BasicAuthorizer) Close() error {
return nil
}

func (o *BasicAuthorizer) GetGinHandler(role string) gin.HandlerFunc {
return func(c *gin.Context) {
o.logger.Debugw("auth basic", "path", c.Request.URL.Path)

userName, password, hasAuth := c.Request.BasicAuth()
if !hasAuth {
c.Writer.Header().Set("WWW-Authenticate", "Basic realm=veraison")
c.AbortWithStatus(http.StatusUnauthorized)
return
}

userInfo, ok := o.users[userName]
if !ok {
c.Writer.Header().Set("WWW-Authenticate", "Basic realm=veraison")
c.AbortWithStatus(http.StatusUnauthorized)
return
}

if err := bcrypt.CompareHashAndPassword(
[]byte(userInfo.Password),
[]byte(password),
); err != nil {
o.logger.Debugf("password check failed: %v", err)
c.Writer.Header().Set("WWW-Authenticate", "Basic realm=veraison")
c.AbortWithStatus(http.StatusUnauthorized)
return
}

gotRole := false
if role == NoRole {
gotRole = true
} else {
for _, userRole := range userInfo.Roles {
if userRole == role {
gotRole = true
break
}
}
}

if gotRole {
log.Debugw("user authenticated", "user", userName, "role", role)
} else {
c.Writer.Header().Set("WWW-Authenticate", "Basic realm=veraison")
c.AbortWithStatus(http.StatusUnauthorized)
}
}
}
25 changes: 25 additions & 0 deletions auth/iauthorizer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Copyright 2023 Contributors to the Veraison project.
// SPDX-License-Identifier: Apache-2.0

package auth

import (
"github.com/gin-gonic/gin"
"github.com/spf13/viper"
"go.uber.org/zap"
)

// IAuthorizer defines the interface that must be implemented by the veraison
// auth backends.
type IAuthorizer interface {
// Init initializes the backend based on the configuration inside the
// provided Viper object and using the provided logger.
Init(v *viper.Viper, logger *zap.SugaredLogger) error
// Close terminates the backend. The exact nature of this method is
// backend-specific.
Close() error
// GetGinHandler returns a gin.HandlerFunc that performs authorization
// based on the specified role. This function can be set as gin
// middleware by passing it to gin.Engine.Use().
GetGinHandler(role string) gin.HandlerFunc
}
Loading

0 comments on commit 9e84803

Please sign in to comment.