diff --git a/changelog/unreleased/auth-app-api.md b/changelog/unreleased/auth-app-api.md new file mode 100644 index 00000000000..a98f9e4b945 --- /dev/null +++ b/changelog/unreleased/auth-app-api.md @@ -0,0 +1,5 @@ +Enhancement: Add an API to auth-app service + +Adds an API to create, list and delete app tokens. Includes an impersonification feature for migration scenarios. + +https://github.com/owncloud/ocis/pull/9755 diff --git a/services/auth-app/README.md b/services/auth-app/README.md index 012490527e9..86896ba0557 100644 --- a/services/auth-app/README.md +++ b/services/auth-app/README.md @@ -21,10 +21,23 @@ PROXY_ENABLE_APP_AUTH=true # mandatory, allow app authentication. In case o ## App Tokens -App Tokens are used to authenticate 3rd party access via https like when using curl (apps) to access an API endpoint. These apps need to authenticate themselves as no logged in user authenticates the request. To be able to use an app token, one must first create a token via the cli. Replace the `user-name` with an existing user. For the `token-expiration`, you can use any time abbreviation from the following list: `h, m, s`. Examples: `72h` or `1h` or `1m` or `1s.` Default is `72h`. +App Tokens are used to authenticate 3rd party access via https like when using curl (apps) to access an API endpoint. These apps need to authenticate themselves as no logged in user authenticates the request. To be able to use an app token, one must first create a token. There are different options of creating a token. + +### Via CLI (dev only) + +Replace the `user-name` with an existing user. For the `token-expiration`, you can use any time abbreviation from the following list: `h, m, s`. Examples: `72h` or `1h` or `1m` or `1s.` Default is `72h`. ```bash ocis auth-app create --user-name={user-name} --expiration={token-expiration} ``` Once generated, these tokens can be used to authenticate requests to ocis. They are passed as part of the request as `Basic Auth` header. + +### Via API + +The `auth-app` service provides an API to create (POST), list (GET) and delete (DELETE) tokens at `/auth-app/tokens`. + +### Via Impersonation API + +When setting the environment variable `AUTH_APP_ENABLE_IMPERSONATION` to `true`, admins will be able to use the `/auth-app/tokens` endpoint to create tokens for other users. This is crucial for migration scenarios, +but should not be used on a productive system. diff --git a/services/auth-app/pkg/config/defaults/defaultconfig.go b/services/auth-app/pkg/config/defaults/defaultconfig.go index ffd7aef80e6..01215d1fe56 100644 --- a/services/auth-app/pkg/config/defaults/defaultconfig.go +++ b/services/auth-app/pkg/config/defaults/defaultconfig.go @@ -36,7 +36,7 @@ func DefaultConfig() *config.Config { Root: "/", CORS: config.CORS{ AllowedOrigins: []string{"*"}, - AllowedMethods: []string{"POST"}, + AllowedMethods: []string{"GET", "POST", "DELETE"}, AllowedHeaders: []string{"Authorization", "Origin", "Content-Type", "Accept", "X-Requested-With", "X-Request-Id", "Ocs-Apirequest"}, AllowCredentials: true, }, diff --git a/services/auth-app/pkg/service/service.go b/services/auth-app/pkg/service/service.go index bb9431b2705..486bafc1b8d 100644 --- a/services/auth-app/pkg/service/service.go +++ b/services/auth-app/pkg/service/service.go @@ -25,6 +25,14 @@ import ( "google.golang.org/grpc/metadata" ) +// AuthAppToken represents an app token. +type AuthAppToken struct { + Token string `json:"token"` + ExpirationDate time.Time `json:"expiration_date"` + CreatedDate time.Time `json:"created_date"` + Label string `json:"label"` +} + // AuthAppService defines the service interface. type AuthAppService struct { log log.Logger @@ -42,7 +50,6 @@ func NewAuthAppService(opts ...Option) (*AuthAppService, error) { } r := roles.NewManager( - // TODO: caching? roles.Logger(o.Logger), roles.RoleService(o.RoleClient), ) @@ -71,120 +78,129 @@ func (a *AuthAppService) ServeHTTP(w http.ResponseWriter, r *http.Request) { // HandleCreate handles the creation of app tokens func (a *AuthAppService) HandleCreate(w http.ResponseWriter, r *http.Request) { + ctx := getContext(r) + sublog := a.log.With().Str("actor", ctxpkg.ContextMustGetUser(ctx).GetId().GetOpaqueId()).Logger() + gwc, err := a.gws.Next() if err != nil { - http.Error(w, "error getting gateway client", http.StatusInternalServerError) + sublog.Error().Err(err).Msg("error getting gateway client") + w.WriteHeader(http.StatusInternalServerError) return } - ctx := getContext(r) - q := r.URL.Query() expiry, err := time.ParseDuration(q.Get("expiry")) if err != nil { - a.log.Info().Err(err).Msg("error parsing expiry") + sublog.Info().Err(err).Str("duration", q.Get("expiry")).Msg("error parsing expiry") http.Error(w, "error parsing expiry. Use e.g. 30m or 72h", http.StatusBadRequest) return } + label := "Generated via API" cid := buildClientID(q.Get("userID"), q.Get("userName")) if cid != "" { if !a.cfg.AllowImpersonation { - a.log.Error().Msg("impersonation is not allowed") + sublog.Error().Msg("impersonation is not allowed") http.Error(w, "impersonation is not allowed", http.StatusForbidden) return } ok, err := isAdmin(ctx, a.r) if err != nil { - a.log.Error().Err(err).Msg("error checking if user is admin") - http.Error(w, "internal server error", http.StatusInternalServerError) + sublog.Error().Err(err).Msg("error checking if user is admin") + w.WriteHeader(http.StatusInternalServerError) return } if !ok { - a.log.Error().Msg("user is not admin") - http.Error(w, "forbidden", http.StatusForbidden) + sublog.Error().Msg("user is not admin") + w.WriteHeader(http.StatusForbidden) return } ctx, err = a.authenticateUser(cid, gwc) if err != nil { - a.log.Error().Err(err).Msg("error authenticating user") - http.Error(w, "error authenticating user", http.StatusInternalServerError) + sublog.Error().Err(err).Msg("error authenticating user") + w.WriteHeader(http.StatusInternalServerError) return } + + label = "Generated via Impersonation API" } scopes, err := scope.AddOwnerScope(map[string]*authpb.Scope{}) if err != nil { - a.log.Error().Err(err).Msg("error adding owner scope") - http.Error(w, "error adding owner scope", http.StatusInternalServerError) + sublog.Error().Err(err).Msg("error adding owner scope") + w.WriteHeader(http.StatusInternalServerError) return } res, err := gwc.GenerateAppPassword(ctx, &applications.GenerateAppPasswordRequest{ TokenScope: scopes, - Label: "Generated via API", + Label: label, Expiration: utils.TimeToTS(time.Now().Add(expiry)), }) if err != nil { - a.log.Error().Err(err).Msg("error generating app password") - http.Error(w, "error generating app password", http.StatusInternalServerError) + sublog.Error().Err(err).Msg("error generating app password") + w.WriteHeader(http.StatusInternalServerError) return } if res.GetStatus().GetCode() != rpc.Code_CODE_OK { - a.log.Error().Str("status", res.GetStatus().GetCode().String()).Msg("error generating app password") - http.Error(w, "error generating app password: "+res.GetStatus().GetMessage(), http.StatusInternalServerError) + sublog.Error().Str("status", res.GetStatus().GetCode().String()).Msg("error generating app password") + w.WriteHeader(http.StatusInternalServerError) return } - b, err := json.Marshal(res.GetAppPassword()) + b, err := json.Marshal(convert(res.GetAppPassword())) if err != nil { - a.log.Error().Err(err).Msg("error marshaling app password") - http.Error(w, "error marshaling app password", http.StatusInternalServerError) + sublog.Error().Err(err).Msg("error marshaling app password") + w.WriteHeader(http.StatusInternalServerError) return } if _, err := w.Write(b); err != nil { - a.log.Error().Err(err).Msg("error writing response") - http.Error(w, "error writing response", http.StatusInternalServerError) - return + sublog.Error().Err(err).Msg("error writing response") } w.WriteHeader(http.StatusOK) } // HandleList handles listing of app tokens func (a *AuthAppService) HandleList(w http.ResponseWriter, r *http.Request) { + ctx := getContext(r) + sublog := a.log.With().Str("actor", ctxpkg.ContextMustGetUser(ctx).GetId().GetOpaqueId()).Logger() + gwc, err := a.gws.Next() if err != nil { - a.log.Error().Err(err).Msg("error getting gateway client") - http.Error(w, "error getting gateway client", http.StatusInternalServerError) + sublog.Error().Err(err).Msg("error getting gateway client") + w.WriteHeader(http.StatusInternalServerError) return } - ctx := getContext(r) - res, err := gwc.ListAppPasswords(ctx, &applications.ListAppPasswordsRequest{}) if err != nil { - a.log.Error().Err(err).Msg("error listing app passwords") - http.Error(w, "error listing app passwords", http.StatusInternalServerError) + sublog.Error().Err(err).Msg("error listing app passwords") + w.WriteHeader(http.StatusInternalServerError) return } if res.GetStatus().GetCode() != rpc.Code_CODE_OK { - a.log.Error().Str("status", res.GetStatus().GetCode().String()).Msg("error listing app passwords") - http.Error(w, "error listing app passwords: "+res.GetStatus().GetMessage(), http.StatusInternalServerError) + sublog.Error().Str("status", res.GetStatus().GetCode().String()).Msg("error listing app passwords") + w.WriteHeader(http.StatusInternalServerError) return } - b, err := json.Marshal(res.GetAppPasswords()) + tokens := make([]AuthAppToken, 0, len(res.GetAppPasswords())) + for _, ap := range res.GetAppPasswords() { + tokens = append(tokens, convert(ap)) + } + + b, err := json.Marshal(tokens) if err != nil { - a.log.Error().Err(err).Msg("error marshaling app passwords") - http.Error(w, "error marshaling app passwords", http.StatusInternalServerError) + sublog.Error().Err(err).Msg("error marshaling app passwords") + w.WriteHeader(http.StatusInternalServerError) return } if _, err := w.Write(b); err != nil { - a.log.Error().Err(err).Msg("error writing response") - http.Error(w, "error writing response", http.StatusInternalServerError) + sublog.Error().Err(err).Msg("error writing response") + w.WriteHeader(http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) @@ -192,32 +208,33 @@ func (a *AuthAppService) HandleList(w http.ResponseWriter, r *http.Request) { // HandleDelete handles deletion of app tokens func (a *AuthAppService) HandleDelete(w http.ResponseWriter, r *http.Request) { + ctx := getContext(r) + sublog := a.log.With().Str("actor", ctxpkg.ContextMustGetUser(ctx).GetId().GetOpaqueId()).Logger() + gwc, err := a.gws.Next() if err != nil { - a.log.Error().Err(err).Msg("error getting gateway client") - http.Error(w, "error getting gateway client", http.StatusInternalServerError) + sublog.Error().Err(err).Msg("error getting gateway client") + w.WriteHeader(http.StatusInternalServerError) return } - ctx := getContext(r) - pw := r.URL.Query().Get("token") if pw == "" { - a.log.Info().Msg("missing token") - http.Error(w, "missing token", http.StatusBadRequest) + sublog.Info().Msg("missing token") + http.Error(w, "missing auth-app token. Set 'token' parameter", http.StatusBadRequest) return } res, err := gwc.InvalidateAppPassword(ctx, &applications.InvalidateAppPasswordRequest{Password: pw}) if err != nil { - a.log.Error().Err(err).Msg("error invalidating app password") - http.Error(w, "error invalidating app password", http.StatusInternalServerError) + sublog.Error().Err(err).Msg("error invalidating app password") + w.WriteHeader(http.StatusInternalServerError) return } if res.GetStatus().GetCode() != rpc.Code_CODE_OK { - a.log.Error().Str("status", res.GetStatus().GetCode().String()).Msg("error invalidating app password") - http.Error(w, "error invalidating app password: "+res.GetStatus().GetMessage(), http.StatusInternalServerError) + sublog.Error().Str("status", res.GetStatus().GetCode().String()).Msg("error invalidating app password") + w.WriteHeader(http.StatusInternalServerError) return } @@ -289,3 +306,12 @@ func isAdmin(ctx context.Context, rm *roles.Manager) (bool, error) { // check if permission is present in roles of the authenticated account return rm.FindPermissionByID(ctx, roleIDs, settings.AccountManagementPermissionID) != nil, nil } + +func convert(ap *applications.AppPassword) AuthAppToken { + return AuthAppToken{ + Token: ap.GetPassword(), + ExpirationDate: utils.TSToTime(ap.GetExpiration()), + CreatedDate: utils.TSToTime(ap.GetCtime()), + Label: ap.GetLabel(), + } +}