Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(api, cli, ui): auth consumer token expiration and last authentication #5822

Merged
merged 14 commits into from
Jun 16, 2021
48 changes: 39 additions & 9 deletions cli/cdsctl/consumer.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"fmt"
"time"

"github.com/pkg/errors"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -86,6 +87,9 @@ var authConsumerNewCmd = cli.Command{
Name: "scopes",
Type: cli.FlagSlice,
Usage: "Define the list of scopes for the consumer",
}, {
Name: "duration",
Usage: "Validity period of the token generated for the consumer (in days)",
},
},
}
Expand Down Expand Up @@ -121,7 +125,7 @@ func authConsumerNewRun(v cli.Values) error {
}
}
if !found {
return errors.Errorf("invalid given group name: '%s'", g)
return errors.Errorf("invalid given group name: %q", g)
}
}
if len(groupIDs) == 0 && !v.GetBool("no-interactive") {
Expand All @@ -139,7 +143,7 @@ func authConsumerNewRun(v cli.Values) error {
for _, s := range v.GetStringSlice("scopes") {
scope := sdk.AuthConsumerScope(s)
if !scope.IsValid() {
return errors.Errorf("invalid given scope value: '%s'", scope)
return errors.Errorf("invalid given scope value: %q", scope)
}
scopes = append(scopes, scope)
}
Expand All @@ -154,11 +158,21 @@ func authConsumerNewRun(v cli.Values) error {
}
}

var duration time.Duration
if v.GetString("duration") != "" {
iDuration, err := v.GetInt64("duration")
if err != nil {
return errors.Errorf("invalid given duration: %q", v.GetString("duration"))
}
duration = time.Duration(iDuration) * (24 * time.Hour)
}

res, err := client.AuthConsumerCreateForUser(username, sdk.AuthConsumer{
Name: name,
Description: description,
GroupIDs: groupIDs,
ScopeDetails: sdk.NewAuthConsumerScopeDetails(scopes...),
Name: name,
Description: description,
GroupIDs: groupIDs,
ScopeDetails: sdk.NewAuthConsumerScopeDetails(scopes...),
ValidityPeriods: sdk.NewAuthConsumerValidityPeriod(time.Now(), duration),
})
if err != nil {
return err
Expand Down Expand Up @@ -195,7 +209,7 @@ func authConsumerDeleteRun(v cli.Values) error {
if err := client.AuthConsumerDelete(username, consumerID); err != nil {
return err
}
fmt.Printf("Consumer '%s' successfully deleted.\n", consumerID)
fmt.Printf("Consumer %q successfully deleted.\n", consumerID)

return nil
}
Expand All @@ -214,6 +228,15 @@ var authConsumerRegenCmd = cli.Command{
Name: consumerIDArg,
},
},
Flags: []cli.Flag{
{
Name: "duration",
Usage: "Validity period of the token generated for the consumer (in days)",
}, {
Name: "overlap",
Usage: "Overlap duration between actual token and the new one. eg: 24h, 30m",
},
},
}

func authConsumerRegenRun(v cli.Values) error {
Expand All @@ -222,12 +245,19 @@ func authConsumerRegenRun(v cli.Values) error {
username = "me"
}

duration, err := v.GetInt64("duration")
if err != nil {
return errors.Errorf("invalid given duration: %q", v.GetString("duration"))
}

overlap := v.GetString("overlap")

consumerID := v.GetString(consumerIDArg)
consumer, err := client.AuthConsumerRegen(username, consumerID)
consumer, err := client.AuthConsumerRegen(username, consumerID, duration, overlap)
if err != nil {
return err
}
fmt.Printf("Consumer '%s' successfully regenerated.\n", consumerID)
fmt.Printf("Consumer %q successfully regenerated.\n", consumerID)
fmt.Printf("Token: %s\n", consumer.Token)

return nil
Expand Down
2 changes: 1 addition & 1 deletion cli/cdsctl/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,7 @@ func createOrRegenConsumer(apiURL, username, sessionToken string, v cli.Values)
}
}
if consumerID != "" {
consumer, err := client.AuthConsumerRegen(username, consumerID)
consumer, err := client.AuthConsumerRegen(username, consumerID, 0, "")
if err != nil {
return "", "", cli.WrapError(err, "cdsctl: cannot regenerate consumer")
}
Expand Down
3 changes: 1 addition & 2 deletions engine/api/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,7 @@ func (api *API) postMaintenanceHandler() service.Handler {

func (api *API) getAdminServicesHandler() service.Handler {
return func(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
srvs := []sdk.Service{}

var srvs []sdk.Service
var err error
if r.FormValue("type") != "" {
srvs, err = services.LoadAllByType(ctx, api.mustDB(), r.FormValue("type"), services.LoadOptions.WithStatus)
Expand Down
12 changes: 9 additions & 3 deletions engine/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,11 @@ type Configuration struct {
InsecureSkipVerifyTLS bool `toml:"insecureSkipVerifyTLS" json:"insecureSkipVerifyTLS" default:"false"`
} `toml:"internalServiceMesh" json:"internalServiceMesh"`
Auth struct {
DefaultGroup string `toml:"defaultGroup" default:"" comment:"The default group is the group in which every new user will be granted at signup" json:"defaultGroup"`
RSAPrivateKey string `toml:"rsaPrivateKey" default:"" comment:"The RSA Private Key used to sign and verify the JWT Tokens issued by the API \nThis is mandatory." json:"-"`
LDAP struct {
TokenDefaultDuration int64 `toml:"tokenDefaultDuration" default:"30" comment:"The default duration of a token (in days)" json:"tokenDefaultDuration"`
TokenOverlapDefaultDuration string `toml:"tokenOverlapDefaultDuration" default:"24h" comment:"The default overlap duration when a token is regen" json:"tokenOverlapDefaultDuration"`
DefaultGroup string `toml:"defaultGroup" default:"" comment:"The default group is the group in which every new user will be granted at signup" json:"defaultGroup"`
RSAPrivateKey string `toml:"rsaPrivateKey" default:"" comment:"The RSA Private Key used to sign and verify the JWT Tokens issued by the API \nThis is mandatory." json:"-"`
LDAP struct {
Enabled bool `toml:"enabled" default:"false" json:"enabled"`
SignupDisabled bool `toml:"signupDisabled" default:"false" json:"signupDisabled"`
Host string `toml:"host" json:"host"`
Expand Down Expand Up @@ -710,6 +712,10 @@ func (a *API) Serve(ctx context.Context) error {
return migrate.RunsSecrets(ctx, a.DBConnectionFactory.GetDBMap(gorpmapping.Mapper))
}})

migrate.Add(ctx, sdk.Migration{Name: "AuthConsumerTokenExpiration", Release: "0.47.0", Blocker: true, Automatic: true, ExecFunc: func(ctx context.Context) error {
return migrate.AuthConsumerTokenExpiration(ctx, a.DBConnectionFactory.GetDBMap(gorpmapping.Mapper), time.Duration(a.Config.Auth.TokenDefaultDuration)*(24*time.Hour))
}})

isFreshInstall, errF := version.IsFreshInstall(a.mustDB())
if errF != nil {
return sdk.WrapError(errF, "Unable to check if it's a fresh installation of CDS")
Expand Down
2 changes: 1 addition & 1 deletion engine/api/application_deployment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ func Test_postApplicationDeploymentStrategyConfigHandlerAsProvider(t *testing.T)
localConsumer, err := authentication.LoadConsumerByTypeAndUserID(context.TODO(), api.mustDB(), sdk.ConsumerLocal, u.ID, authentication.LoadConsumerOptions.WithAuthentifiedUser)
require.NoError(t, err)

_, jws, err := builtin.NewConsumer(context.TODO(), db, sdk.RandomString(10), sdk.RandomString(10), localConsumer, u.GetGroupIDs(),
_, jws, err := builtin.NewConsumer(context.TODO(), db, sdk.RandomString(10), sdk.RandomString(10), 0, localConsumer, u.GetGroupIDs(),
sdk.NewAuthConsumerScopeDetails(sdk.AuthConsumerScopeProject))

pkey := sdk.RandomString(10)
Expand Down
2 changes: 1 addition & 1 deletion engine/api/application_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ func Test_postApplicationMetadataHandler_AsProvider(t *testing.T) {
u, _ := assets.InsertAdminUser(t, db)
localConsumer, err := authentication.LoadConsumerByTypeAndUserID(context.TODO(), api.mustDB(), sdk.ConsumerLocal, u.ID, authentication.LoadConsumerOptions.WithAuthentifiedUser)
require.NoError(t, err)
_, jws, err := builtin.NewConsumer(context.TODO(), db, sdk.RandomString(10), sdk.RandomString(10), localConsumer, u.GetGroupIDs(),
_, jws, err := builtin.NewConsumer(context.TODO(), db, sdk.RandomString(10), sdk.RandomString(10), 0, localConsumer, u.GetGroupIDs(),
sdk.NewAuthConsumerScopeDetails(sdk.AuthConsumerScopeProject))

pkey := sdk.RandomString(10)
Expand Down
8 changes: 8 additions & 0 deletions engine/api/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package api
import (
"context"
"net/http"
"time"

"github.com/gorilla/mux"
"github.com/rockbears/log"
Expand Down Expand Up @@ -273,6 +274,13 @@ func (api *API) postAuthSigninHandler() service.Handler {
return err
}

// Store the last authentication date on the consumer
now := time.Now()
consumer.LastAuthentication = &now
if err := authentication.UpdateConsumerLastAuthentication(ctx, tx, consumer); err != nil {
return err
}

log.Debug(ctx, "postAuthSigninHandler> new session %s created for %.2f seconds: %+v", session.ID, sessionDuration.Seconds(), session)

// Generate a jwt for current session
Expand Down
24 changes: 16 additions & 8 deletions engine/api/auth_builtin.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/base64"
"net/http"
"time"

"github.com/ovh/cds/engine/api/authentication"
"github.com/ovh/cds/engine/api/authentication/builtin"
Expand All @@ -18,38 +19,38 @@ func (api *API) postAuthBuiltinSigninHandler() service.Handler {
// Get the consumer builtin driver
driver, ok := api.AuthenticationDrivers[sdk.ConsumerBuiltin]
if !ok {
return sdk.WithStack(sdk.ErrNotFound)
return sdk.WithStack(sdk.ErrForbidden)
}

// Extract and validate signin request
var req sdk.AuthConsumerSigninRequest
if err := service.UnmarshalBody(r, &req); err != nil {
return err
return sdk.NewError(sdk.ErrForbidden, err)
}
if err := driver.CheckSigninRequest(req); err != nil {
return err
return sdk.NewError(sdk.ErrForbidden, err)
}
// Convert code to external user info
userInfo, err := driver.GetUserInfo(ctx, req)
if err != nil {
return err
return sdk.NewError(sdk.ErrForbidden, err)
}

tx, err := api.mustDB().Begin()
if err != nil {
return sdk.WithStack(err)
return sdk.NewError(sdk.ErrForbidden, err)
}
defer tx.Rollback() // nolint

// Check if a consumer exists for consumer type and external user identifier
consumer, err := authentication.LoadConsumerByID(ctx, tx, userInfo.ExternalID)
if err != nil {
return err
return sdk.NewError(sdk.ErrForbidden, err)
}

// Check the Token validity againts the IAT attribute
if _, err := builtin.CheckSigninConsumerTokenIssuedAt(req["token"], consumer.IssuedAt); err != nil {
return err
if _, err := builtin.CheckSigninConsumerTokenIssuedAt(ctx, req["token"], consumer); err != nil {
return sdk.NewError(sdk.ErrForbidden, err)
}

// Generate a new session for consumer
Expand All @@ -58,6 +59,13 @@ func (api *API) postAuthBuiltinSigninHandler() service.Handler {
return err
}

// Store the last authentication date on the consumer
now := time.Now()
consumer.LastAuthentication = &now
if err := authentication.UpdateConsumerLastAuthentication(ctx, tx, consumer); err != nil {
return err
}

// Generate a jwt for current session
jwt, err := authentication.NewSessionJWT(session, "")
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion engine/api/auth_builtin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ func Test_postAuthBuiltinSigninHandler(t *testing.T) {
localConsumer, err := authentication.LoadConsumerByTypeAndUserID(context.TODO(), api.mustDB(), sdk.ConsumerLocal, usr.ID, authentication.LoadConsumerOptions.WithAuthentifiedUser)
require.NoError(t, err)

_, jws, err := builtin.NewConsumer(context.TODO(), db, sdk.RandomString(10), sdk.RandomString(10), localConsumer, usr.GetGroupIDs(),
_, jws, err := builtin.NewConsumer(context.TODO(), db, sdk.RandomString(10), sdk.RandomString(10), 0, localConsumer, usr.GetGroupIDs(),
sdk.NewAuthConsumerScopeDetails(sdk.AuthConsumerScopeProject))
require.NoError(t, err)
AuthentififyBuiltinConsumer(t, api, jws)
Expand Down
33 changes: 31 additions & 2 deletions engine/api/auth_consumer.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ package api
import (
"context"
"net/http"
"time"

"github.com/gorilla/mux"
"github.com/ovh/cds/sdk"
"github.com/pkg/errors"
"github.com/rockbears/log"

"github.com/ovh/cds/engine/api/authentication"
Expand Down Expand Up @@ -77,8 +79,12 @@ func (api *API) postConsumerByUserHandler() service.Handler {
return err
}

if reqData.ValidityPeriods.Latest() == nil {
reqData.ValidityPeriods = sdk.NewAuthConsumerValidityPeriod(time.Now(), time.Duration(api.Config.Auth.TokenDefaultDuration)*(24*time.Hour))
}

// Create the new built in consumer from request data
newConsumer, token, err := builtin.NewConsumer(ctx, tx, reqData.Name, reqData.Description,
newConsumer, token, err := builtin.NewConsumer(ctx, tx, reqData.Name, reqData.Description, reqData.ValidityPeriods.Latest().Duration,
consumer, reqData.GroupIDs, reqData.ScopeDetails)
if err != nil {
return err
Expand Down Expand Up @@ -153,7 +159,30 @@ func (api *API) postConsumerRegenByUserHandler() service.Handler {
return err
}

if err := authentication.ConsumerRegen(ctx, tx, consumer); err != nil {
if req.OverlapDuration == "" {
req.OverlapDuration = api.Config.Auth.TokenOverlapDefaultDuration
}
if req.NewDuration == 0 {
req.NewDuration = api.Config.Auth.TokenDefaultDuration
}
var overlapDuration time.Duration
if req.OverlapDuration != "" {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should check that overlap duration is less than duration ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

overlapDuration, err = time.ParseDuration(req.OverlapDuration)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not both overlap and validity duration in same format (string duration vs count of hours) ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

because overlap should be in minutes or hours, and validity should be a matter of days

if err != nil {
return sdk.NewError(sdk.ErrWrongRequest, err)
}
}

newDuration := time.Duration(req.NewDuration) * (24 * time.Hour)

if overlapDuration > newDuration {
return sdk.NewError(sdk.ErrWrongRequest, errors.New("invalid duration"))
}

if err := authentication.ConsumerRegen(ctx, tx, consumer,
overlapDuration,
newDuration,
); err != nil {
return err
}

Expand Down
Loading