feat(auth): integrate oauth extension (#4152)

* refactor(oauth): move oauth client code

* feat(oauth): move extension code into server code

* feat(oauth): enable oauth without extension

* refactor(oauth): make it easier to remove providers
pull/4156/head
Chaim Lev-Ari 2020-08-05 11:36:46 +03:00 committed by GitHub
parent 148ccd1bc4
commit 00f4fe0039
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 201 additions and 135 deletions

View File

@ -6,7 +6,7 @@ import (
"strings"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/bolt"
"github.com/portainer/portainer/api/chisel"
"github.com/portainer/portainer/api/cli"
@ -24,6 +24,7 @@ import (
kubecli "github.com/portainer/portainer/api/kubernetes/cli"
"github.com/portainer/portainer/api/ldap"
"github.com/portainer/portainer/api/libcompose"
"github.com/portainer/portainer/api/oauth"
)
func initCLI() *portainer.CLIFlags {
@ -108,6 +109,10 @@ func initLDAPService() portainer.LDAPService {
return &ldap.Service{}
}
func initOAuthService() portainer.OAuthService {
return oauth.NewService()
}
func initGitService() portainer.GitService {
return git.NewService()
}
@ -354,6 +359,8 @@ func main() {
ldapService := initLDAPService()
oauthService := initOAuthService()
gitService := initGitService()
cryptoService := initCryptoService()
@ -467,6 +474,7 @@ func main() {
JWTService: jwtService,
FileService: fileService,
LDAPService: ldapService,
OAuthService: oauthService,
GitService: gitService,
SignatureService: digitalSignatureService,
SnapshotService: snapshotService,

View File

@ -30,6 +30,7 @@ require (
github.com/portainer/libhttp v0.0.0-20190806161843-ba068f58be33
golang.org/x/crypto v0.0.0-20191128160524-b544559bb6d1
golang.org/x/net v0.0.0-20191126235420-ef20fe5d7933 // indirect
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45
gopkg.in/alecthomas/kingpin.v2 v2.2.6
gopkg.in/src-d/go-git.v4 v4.13.1
k8s.io/api v0.17.2

View File

@ -1,9 +1,7 @@
package auth
import (
"encoding/json"
"errors"
"io/ioutil"
"log"
"net/http"
@ -27,52 +25,22 @@ func (payload *oauthPayload) Validate(r *http.Request) error {
return nil
}
func (handler *Handler) authenticateThroughExtension(code, licenseKey string, settings *portainer.OAuthSettings) (string, error) {
extensionURL := handler.ProxyManager.GetExtensionURL(portainer.OAuthAuthenticationExtension)
func (handler *Handler) authenticateOAuth(code string, settings *portainer.OAuthSettings) (string, error) {
if code == "" {
return "", errors.New("Invalid OAuth authorization code")
}
encodedConfiguration, err := json.Marshal(settings)
if settings == nil {
return "", errors.New("Invalid OAuth configuration")
}
username, err := handler.OAuthService.Authenticate(code, settings)
if err != nil {
log.Printf("[DEBUG] - Unable to authenticate user via OAuth: %v", err)
return "", nil
}
req, err := http.NewRequest("GET", extensionURL+"/validate", nil)
if err != nil {
return "", err
}
client := &http.Client{}
req.Header.Set("X-OAuth-Config", string(encodedConfiguration))
req.Header.Set("X-OAuth-Code", code)
req.Header.Set("X-PortainerExtension-License", licenseKey)
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return "", err
}
type extensionResponse struct {
Username string `json:"Username,omitempty"`
Err string `json:"err,omitempty"`
Details string `json:"details,omitempty"`
}
var extResp extensionResponse
err = json.Unmarshal(body, &extResp)
if err != nil {
return "", err
}
if resp.StatusCode != http.StatusOK {
return "", errors.New(extResp.Err + ":" + extResp.Details)
}
return extResp.Username, nil
return username, nil
}
func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
@ -91,14 +59,7 @@ func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *h
return &httperror.HandlerError{http.StatusForbidden, "OAuth authentication is not enabled", errors.New("OAuth authentication is not enabled")}
}
extension, err := handler.DataStore.Extension().Extension(portainer.OAuthAuthenticationExtension)
if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Oauth authentication extension is not enabled", err}
} else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a extension with the specified identifier inside the database", err}
}
username, err := handler.authenticateThroughExtension(payload.Code, extension.License.LicenseKey, &settings.OAuthSettings)
username, err := handler.authenticateOAuth(payload.Code, &settings.OAuthSettings)
if err != nil {
log.Printf("[DEBUG] - OAuth authentication error: %s", err)
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to authenticate through OAuth", httperrors.ErrUnauthorized}

View File

@ -19,6 +19,7 @@ type Handler struct {
CryptoService portainer.CryptoService
JWTService portainer.JWTService
LDAPService portainer.LDAPService
OAuthService portainer.OAuthService
ProxyManager *proxy.Manager
AuthorizationService *authorization.Service
KubernetesTokenCacheManager *kubernetes.TokenCacheManager

View File

@ -61,6 +61,7 @@ type Server struct {
GitService portainer.GitService
JWTService portainer.JWTService
LDAPService portainer.LDAPService
OAuthService portainer.OAuthService
SwarmStackManager portainer.SwarmStackManager
Handler *handler.Handler
SSL bool
@ -90,6 +91,7 @@ func (server *Server) Start() error {
authHandler.ProxyManager = proxyManager
authHandler.AuthorizationService = authorizationService
authHandler.KubernetesTokenCacheManager = kubernetesTokenCacheManager
authHandler.OAuthService = server.OAuthService
var roleHandler = roles.NewHandler(requestBouncer)
roleHandler.DataStore = server.DataStore

130
api/oauth/oauth.go Normal file
View File

@ -0,0 +1,130 @@
package oauth
import (
"context"
"encoding/json"
"fmt"
"golang.org/x/oauth2"
"io/ioutil"
"log"
"mime"
"net/http"
"net/url"
"github.com/portainer/portainer/api"
)
// Service represents a service used to authenticate users against an authorization server
type Service struct{}
// NewService returns a pointer to a new instance of this service
func NewService() *Service {
return &Service{}
}
// Authenticate takes an access code and exchanges it for an access token from portainer OAuthSettings token endpoint.
// On success, it will then return the username associated to authenticated user by fetching this information
// from the resource server and matching it with the user identifier setting.
func (*Service) Authenticate(code string, configuration *portainer.OAuthSettings) (string, error) {
token, err := getAccessToken(code, configuration)
if err != nil {
log.Printf("[DEBUG] - Failed retrieving access token: %v", err)
return "", err
}
return getUsername(token, configuration)
}
func getAccessToken(code string, configuration *portainer.OAuthSettings) (string, error) {
unescapedCode, err := url.QueryUnescape(code)
if err != nil {
return "", err
}
config := buildConfig(configuration)
token, err := config.Exchange(context.Background(), unescapedCode)
if err != nil {
return "", err
}
return token.AccessToken, nil
}
func getUsername(token string, configuration *portainer.OAuthSettings) (string, error) {
req, err := http.NewRequest("GET", configuration.ResourceURI, nil)
if err != nil {
return "", err
}
client := &http.Client{}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode != http.StatusOK {
return "", &oauth2.RetrieveError{
Response: resp,
Body: body,
}
}
content, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
if err != nil {
return "", err
}
if content == "application/x-www-form-urlencoded" || content == "text/plain" {
values, err := url.ParseQuery(string(body))
if err != nil {
return "", err
}
username := values.Get(configuration.UserIdentifier)
return username, nil
}
var datamap map[string]interface{}
if err = json.Unmarshal(body, &datamap); err != nil {
return "", err
}
username, ok := datamap[configuration.UserIdentifier].(string)
if ok && username != "" {
return username, nil
}
if !ok {
username, ok := datamap[configuration.UserIdentifier].(float64)
if ok && username != 0 {
return fmt.Sprint(int(username)), nil
}
}
return "", &oauth2.RetrieveError{
Response: resp,
Body: body,
}
}
func buildConfig(configuration *portainer.OAuthSettings) *oauth2.Config {
endpoint := oauth2.Endpoint{
AuthURL: configuration.AuthorizationURI,
TokenURL: configuration.AccessTokenURI,
}
return &oauth2.Config{
ClientID: configuration.ClientID,
ClientSecret: configuration.ClientSecret,
Endpoint: endpoint,
RedirectURL: configuration.RedirectURI,
Scopes: []string{configuration.Scopes},
}
}

View File

@ -984,6 +984,11 @@ type (
GetUserGroups(username string, settings *LDAPSettings) ([]string, error)
}
// OAuthService represents a service used to authenticate users using OAuth
OAuthService interface {
Authenticate(code string, configuration *OAuthSettings) (string, error)
}
// RegistryService represents a service for managing registry data
RegistryService interface {
Registry(ID RegistryID) (*Registry, error)

View File

@ -1 +1 @@
angular.module('portainer.extensions', ['portainer.extensions.registrymanagement', 'portainer.extensions.oauth', 'portainer.extensions.rbac']);
angular.module('portainer.extensions', ['portainer.extensions.registrymanagement', 'portainer.extensions.rbac']);

View File

@ -1 +0,0 @@
angular.module('portainer.extensions.oauth', ['ngResource']).constant('API_ENDPOINT_OAUTH', 'api/auth/oauth');

View File

@ -1,49 +0,0 @@
<div class="col-sm-12 form-section-title">
Provider
</div>
<div class="form-group"></div>
<div class="form-group" style="margin-bottom: 0;">
<div class="boxselector_wrapper">
<div ng-click="$ctrl.onSelect($ctrl.provider, true)">
<input type="radio" id="oauth_provider_microsoft" ng-model="$ctrl.provider" ng-value="$ctrl.providers[0]" />
<label for="oauth_provider_microsoft">
<div class="boxselector_header">
<i class="fab fa-microsoft" aria-hidden="true" style="margin-right: 2px;"></i>
Microsoft
</div>
<p>Microsoft OAuth provider</p>
</label>
</div>
<div ng-click="$ctrl.onSelect($ctrl.provider, true)">
<input type="radio" id="oauth_provider_google" ng-model="$ctrl.provider" ng-value="$ctrl.providers[1]" />
<label for="oauth_provider_google">
<div class="boxselector_header">
<i class="fab fa-google" aria-hidden="true" style="margin-right: 2px;"></i>
Google
</div>
<p>Google OAuth provider</p>
</label>
</div>
<div ng-click="$ctrl.onSelect($ctrl.provider, true)">
<input type="radio" id="oauth_provider_github" ng-model="$ctrl.provider" ng-value="$ctrl.providers[2]" />
<label for="oauth_provider_github">
<div class="boxselector_header">
<i class="fab fa-github" aria-hidden="true" style="margin-right: 2px;"></i>
Github
</div>
<p>Github OAuth provider</p>
</label>
</div>
<div ng-click="$ctrl.onSelect($ctrl.provider, true)">
<input type="radio" id="oauth_provider_custom" ng-model="$ctrl.provider" ng-value="$ctrl.providers[3]" />
<label for="oauth_provider_custom">
<div class="boxselector_header">
<i class="fa fa-user-check" aria-hidden="true" style="margin-right: 2px;"></i>
Custom
</div>
<p>Custom OAuth provider</p>
</label>
</div>
</div>
</div>

View File

@ -25,7 +25,7 @@ function initAnalytics(Analytics, $rootScope) {
});
}
angular.module('portainer.app', []).config([
angular.module('portainer.app', ['portainer.oauth']).config([
'$stateRegistryProvider',
function ($stateRegistryProvider) {
'use strict';

View File

@ -0,0 +1 @@
angular.module('portainer.oauth', ['ngResource']).constant('API_ENDPOINT_OAUTH', 'api/auth/oauth');

View File

@ -1,4 +1,4 @@
angular.module('portainer.extensions.oauth').controller('OAuthProviderSelectorController', function OAuthProviderSelectorController() {
angular.module('portainer.oauth').controller('OAuthProviderSelectorController', function OAuthProviderSelectorController() {
var ctrl = this;
this.providers = [
@ -9,6 +9,9 @@ angular.module('portainer.extensions.oauth').controller('OAuthProviderSelectorCo
userIdentifier: 'userPrincipalName',
scopes: 'id,email,name',
name: 'microsoft',
label: 'Microsoft',
description: 'Microsoft OAuth provider',
icon: 'fab fa-microsoft',
},
{
authUrl: 'https://accounts.google.com/o/oauth2/auth',
@ -17,6 +20,9 @@ angular.module('portainer.extensions.oauth').controller('OAuthProviderSelectorCo
userIdentifier: 'email',
scopes: 'profile email',
name: 'google',
label: 'Google',
description: 'Google OAuth provider',
icon: 'fab fa-google',
},
{
authUrl: 'https://github.com/login/oauth/authorize',
@ -25,6 +31,9 @@ angular.module('portainer.extensions.oauth').controller('OAuthProviderSelectorCo
userIdentifier: 'login',
scopes: 'id email name',
name: 'github',
label: 'Github',
description: 'Github OAuth provider',
icon: 'fab fa-github',
},
{
authUrl: '',
@ -33,6 +42,9 @@ angular.module('portainer.extensions.oauth').controller('OAuthProviderSelectorCo
userIdentifier: '',
scopes: '',
name: 'custom',
label: 'Custom',
description: 'Custom OAuth provider',
icon: 'fa fa-user-check',
},
];

View File

@ -0,0 +1,19 @@
<div class="col-sm-12 form-section-title">
Provider
</div>
<div class="form-group"></div>
<div class="form-group" style="margin-bottom: 0;">
<div class="boxselector_wrapper">
<div ng-repeat="provider in $ctrl.providers" ng-click="$ctrl.onSelect(provider, true)">
<input type="radio" id="{{ 'oauth_provider_' + provider.name }}" ng-model="$ctrl.provider" ng-value="provider" />
<label for="{{ 'oauth_provider_' + provider.name }}">
<div class="boxselector_header">
<i ng-class="provider.icon" aria-hidden="true" style="margin-right: 2px;"></i>
{{ provider.label }}
</div>
<p>{{ provider.description }}</p>
</label>
</div>
</div>
</div>

View File

@ -1,4 +1,4 @@
angular.module('portainer.extensions.oauth').component('oauthProvidersSelector', {
angular.module('portainer.oauth').component('oauthProvidersSelector', {
templateUrl: './oauth-providers-selector.html',
bindings: {
onSelect: '<',

View File

@ -1,6 +1,6 @@
import _ from 'lodash-es';
angular.module('portainer.extensions.oauth').controller('OAuthSettingsController', function OAuthSettingsController() {
angular.module('portainer.oauth').controller('OAuthSettingsController', function OAuthSettingsController() {
var ctrl = this;
this.state = {

View File

@ -1,4 +1,4 @@
angular.module('portainer.extensions.oauth').component('oauthSettings', {
angular.module('portainer.oauth').component('oauthSettings', {
templateUrl: './oauth-settings.html',
bindings: {
settings: '=',

View File

@ -1,4 +1,4 @@
angular.module('portainer.extensions.oauth').factory('OAuth', [
angular.module('portainer.oauth').factory('OAuth', [
'$resource',
'API_ENDPOINT_OAUTH',
function OAuthFactory($resource, API_ENDPOINT_OAUTH) {

View File

@ -57,7 +57,7 @@
<p>LDAP authentication</p>
</label>
</div>
<div ng-if="oauthAuthenticationAvailable">
<div>
<input type="radio" id="registry_auth" ng-model="settings.AuthenticationMethod" ng-value="3" />
<label for="registry_auth">
<div class="boxselector_header">
@ -67,23 +67,6 @@
<p>OAuth authentication</p>
</label>
</div>
<div style="color: #767676;" ng-click="goToOAuthExtensionView()" ng-if="!oauthAuthenticationAvailable">
<input type="radio" id="registry_auth" ng-model="settings.AuthenticationMethod" ng-value="3" disabled />
<label
for="registry_auth"
tooltip-append-to-body="true"
tooltip-placement="bottom"
tooltip-class="portainer-tooltip"
uib-tooltip="Feature available via an extension"
style="cursor: pointer; border-color: #767676;"
>
<div class="boxselector_header">
<i class="fa fa-users" aria-hidden="true" style="margin-right: 2px;"></i>
OAuth (extension)
</div>
<p>OAuth authentication</p>
</label>
</div>
</div>
</div>

View File

@ -6,8 +6,7 @@ angular.module('portainer.app').controller('SettingsAuthenticationController', [
'SettingsService',
'FileUploadService',
'TeamService',
'ExtensionService',
function ($q, $scope, $state, Notifications, SettingsService, FileUploadService, TeamService, ExtensionService) {
function ($q, $scope, $state, Notifications, SettingsService, FileUploadService, TeamService) {
$scope.state = {
successfulConnectivityCheck: false,
failedConnectivityCheck: false,
@ -68,10 +67,6 @@ angular.module('portainer.app').controller('SettingsAuthenticationController', [
},
};
$scope.goToOAuthExtensionView = function () {
$state.go('portainer.extensions.extension', { id: 2 });
};
$scope.isOauthEnabled = function isOauthEnabled() {
return $scope.settings && $scope.settings.AuthenticationMethod === 3;
};
@ -167,7 +162,6 @@ angular.module('portainer.app').controller('SettingsAuthenticationController', [
$q.all({
settings: SettingsService.settings(),
teams: TeamService.teams(),
oauthAuthentication: ExtensionService.extensionEnabled(ExtensionService.EXTENSIONS.OAUTH_AUTHENTICATION),
})
.then(function success(data) {
var settings = data.settings;
@ -176,7 +170,6 @@ angular.module('portainer.app').controller('SettingsAuthenticationController', [
$scope.formValues.LDAPSettings = settings.LDAPSettings;
$scope.OAuthSettings = settings.OAuthSettings;
$scope.formValues.TLSCACert = settings.LDAPSettings.TLSConfig.TLSCACert;
$scope.oauthAuthenticationAvailable = data.oauthAuthentication;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve application settings');