feat(oauth): dev build supporting Oauth extension

pull/2749/head
Anthony Lapenna 2019-02-18 14:46:34 +13:00
parent 086bad2956
commit 7643f8d08c
13 changed files with 114 additions and 178 deletions

View File

@ -20,7 +20,6 @@ import (
"github.com/portainer/portainer/jwt"
"github.com/portainer/portainer/ldap"
"github.com/portainer/portainer/libcompose"
"github.com/portainer/portainer/oauth"
"log"
)
@ -101,10 +100,6 @@ func initLDAPService() portainer.LDAPService {
return &ldap.Service{}
}
func initOAuthService() portainer.OAuthService {
return &oauth.Service{}
}
func initGitService() portainer.GitService {
return &git.Service{}
}
@ -529,8 +524,6 @@ func main() {
ldapService := initLDAPService()
oauthService := initOAuthService()
gitService := initGitService()
cryptoService := initCryptoService()
@ -676,7 +669,6 @@ func main() {
JWTService: jwtService,
FileService: fileService,
LDAPService: ldapService,
OAuthService: oauthService,
GitService: gitService,
SignatureService: digitalSignatureService,
JobScheduler: jobScheduler,

View File

@ -18,7 +18,8 @@ import (
var extensionDownloadBaseURL = "https://portainer-io-assets.sfo2.digitaloceanspaces.com/extensions/"
var extensionBinaryMap = map[portainer.ExtensionID]string{
portainer.RegistryManagementExtension: "extension-registry-management",
portainer.RegistryManagementExtension: "extension-registry-management",
portainer.OAuthAuthenticationExtension: "extension-oauth-authentication",
}
// ExtensionManager represents a service used to

View File

@ -1,7 +1,8 @@
package auth
import (
"log"
"encoding/json"
"io/ioutil"
"net/http"
"github.com/asaskevich/govalidator"
@ -21,6 +22,54 @@ 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)
encodedConfiguration, err := json.Marshal(settings)
if err != nil {
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 "", portainer.Error(extResp.Err + ":" + extResp.Details)
}
return extResp.Username, nil
}
func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload oauthPayload
err := request.DecodeAndValidateJSONPayload(r, &payload)
@ -37,16 +86,16 @@ func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *h
return &httperror.HandlerError{http.StatusForbidden, "OAuth authentication is not enabled", err}
}
token, err := handler.OAuthService.GetAccessToken(payload.Code, &settings.OAuthSettings)
if err != nil {
log.Printf("[DEBUG] - Failed retrieving access token: %v", err)
return &httperror.HandlerError{http.StatusUnprocessableEntity, "Invalid access token", portainer.ErrUnauthorized}
extension, err := handler.ExtensionService.Extension(portainer.OAuthAuthenticationExtension)
if err == portainer.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.OAuthService.GetUsername(token, &settings.OAuthSettings)
username, err := handler.authenticateThroughExtension(payload.Code, extension.License.LicenseKey, &settings.OAuthSettings)
if err != nil {
log.Printf("[DEBUG] - Failed acquiring username: %v", err)
return &httperror.HandlerError{http.StatusForbidden, "Unable to acquire username", portainer.ErrUnauthorized}
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to authenticate through OAuth", portainer.ErrUnauthorized}
}
user, err := handler.UserService.UserByUsername(username)
@ -85,18 +134,3 @@ func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *h
return handler.writeToken(w, user)
}
func (handler *Handler) loginOAuth(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
settings, err := handler.SettingsService.Settings()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err}
}
if settings.AuthenticationMethod != 3 {
return &httperror.HandlerError{http.StatusForbidden, "OAuth authentication is disabled", err}
}
url := handler.OAuthService.BuildLoginURL(&settings.OAuthSettings)
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
return nil
}

View File

@ -6,6 +6,7 @@ import (
"github.com/gorilla/mux"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/portainer"
"github.com/portainer/portainer/http/proxy"
"github.com/portainer/portainer/http/security"
)
@ -25,10 +26,11 @@ type Handler struct {
CryptoService portainer.CryptoService
JWTService portainer.JWTService
LDAPService portainer.LDAPService
OAuthService portainer.OAuthService
SettingsService portainer.SettingsService
TeamService portainer.TeamService
TeamMembershipService portainer.TeamMembershipService
ExtensionService portainer.ExtensionService
ProxyManager *proxy.Manager
}
// NewHandler creates a handler to manage authentication operations.
@ -38,8 +40,6 @@ func NewHandler(bouncer *security.RequestBouncer, rateLimiter *security.RateLimi
authDisabled: authDisabled,
}
h.Handle("/auth/oauth/login",
rateLimiter.LimitAccess(bouncer.PublicAccess(httperror.LoggerHandler(h.loginOAuth)))).Methods(http.MethodGet)
h.Handle("/auth/oauth/validate",
rateLimiter.LimitAccess(bouncer.PublicAccess(httperror.LoggerHandler(h.validateOAuth)))).Methods(http.MethodPost)
h.Handle("/auth",

View File

@ -19,7 +19,6 @@ type Handler struct {
*mux.Router
SettingsService portainer.SettingsService
LDAPService portainer.LDAPService
OAuthService portainer.OAuthService
FileService portainer.FileService
JobScheduler portainer.JobScheduler
ScheduleService portainer.ScheduleService

View File

@ -33,6 +33,7 @@ func (handler *Handler) settingsPublic(w http.ResponseWriter, r *http.Request) *
AllowPrivilegedModeForRegularUsers: settings.AllowPrivilegedModeForRegularUsers,
EnableHostManagementFeatures: settings.EnableHostManagementFeatures,
ExternalTemplates: false,
// TODO: check if state=portainer useful or not
OAuthLoginURI: fmt.Sprintf("%s?response_type=code&client_id=%s&redirect_uri=%s&scope=%s&state=portainer",
settings.OAuthSettings.AuthorizationURI,
settings.OAuthSettings.ClientID,

View File

@ -12,7 +12,8 @@ import (
// TODO: contain code related to legacy extension management
var extensionPorts = map[portainer.ExtensionID]string{
portainer.RegistryManagementExtension: "7001",
portainer.RegistryManagementExtension: "7001",
portainer.OAuthAuthenticationExtension: "7002",
}
type (
@ -103,6 +104,10 @@ func (manager *Manager) CreateExtensionProxy(extensionID portainer.ExtensionID)
return proxy, nil
}
func (manager *Manager) GetExtensionURL(extensionID portainer.ExtensionID) string {
return "http://localhost:" + extensionPorts[extensionID]
}
// DeleteExtensionProxy deletes the extension proxy associated to an extension identifier
func (manager *Manager) DeleteExtensionProxy(extensionID portainer.ExtensionID) {
manager.extensionProxies.Remove(strconv.Itoa(int(extensionID)))

View File

@ -55,7 +55,6 @@ type Server struct {
GitService portainer.GitService
JWTService portainer.JWTService
LDAPService portainer.LDAPService
OAuthService portainer.OAuthService
ExtensionService portainer.ExtensionService
RegistryService portainer.RegistryService
ResourceControlService portainer.ResourceControlService
@ -105,10 +104,11 @@ func (server *Server) Start() error {
authHandler.CryptoService = server.CryptoService
authHandler.JWTService = server.JWTService
authHandler.LDAPService = server.LDAPService
authHandler.OAuthService = server.OAuthService
authHandler.SettingsService = server.SettingsService
authHandler.TeamService = server.TeamService
authHandler.TeamMembershipService = server.TeamMembershipService
authHandler.ExtensionService = server.ExtensionService
authHandler.ProxyManager = proxyManager
var dockerHubHandler = dockerhub.NewHandler(requestBouncer)
dockerHubHandler.DockerHubService = server.DockerHubService
@ -157,7 +157,6 @@ func (server *Server) Start() error {
var settingsHandler = settings.NewHandler(requestBouncer)
settingsHandler.SettingsService = server.SettingsService
settingsHandler.LDAPService = server.LDAPService
settingsHandler.OAuthService = server.OAuthService
settingsHandler.FileService = server.FileService
settingsHandler.JobScheduler = server.JobScheduler
settingsHandler.ScheduleService = server.ScheduleService

View File

@ -1,123 +0,0 @@
package oauth
import (
"context"
"encoding/json"
"fmt"
"io/ioutil"
"mime"
"net/http"
"net/url"
"github.com/portainer/portainer"
"golang.org/x/oauth2"
)
const (
// ErrInvalidCode defines an error raised when the user authorization code is invalid
ErrInvalidCode = portainer.Error("Invalid OAuth authorization code")
)
// Service represents a service used to authenticate users against an authorization server
type Service struct{}
// GetAccessToken takes an access code and exchanges it for an access token from portainer OAuthSettings token endpoint
func (*Service) GetAccessToken(code string, settings *portainer.OAuthSettings) (string, error) {
unescapedCode, err := url.QueryUnescape(code)
if err != nil {
return "", err
}
config := buildConfig(settings)
token, err := config.Exchange(context.Background(), unescapedCode)
if err != nil {
return "", err
}
return token.AccessToken, nil
}
// GetUsername takes a token and retrieves the portainer OAuthSettings user identifier from resource server.
func (*Service) GetUsername(token string, settings *portainer.OAuthSettings) (string, error) {
req, err := http.NewRequest("GET", settings.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(settings.UserIdentifier)
return username, nil
}
var datamap map[string]interface{}
if err = json.Unmarshal(body, &datamap); err != nil {
return "", err
}
username, ok := datamap[settings.UserIdentifier].(string)
if ok && username != "" {
return username, nil
}
if !ok {
username, ok := datamap[settings.UserIdentifier].(float64)
if ok && username != 0 {
return fmt.Sprint(int(username)), nil
}
}
return "", &oauth2.RetrieveError{
Response: resp,
Body: body,
}
}
// BuildLoginURL creates a login url for the oauth provider
func (*Service) BuildLoginURL(oauthSettings *portainer.OAuthSettings) string {
oauthConfig := buildConfig(oauthSettings)
return oauthConfig.AuthCodeURL("portainer")
}
func buildConfig(oauthSettings *portainer.OAuthSettings) *oauth2.Config {
endpoint := oauth2.Endpoint{
AuthURL: oauthSettings.AuthorizationURI,
TokenURL: oauthSettings.AccessTokenURI,
}
return &oauth2.Config{
ClientID: oauthSettings.ClientID,
ClientSecret: oauthSettings.ClientSecret,
Endpoint: endpoint,
RedirectURL: oauthSettings.RedirectURI,
Scopes: []string{oauthSettings.Scopes},
}
}

View File

@ -67,7 +67,7 @@ type (
UserIdentifier string `json:"UserIdentifier"`
Scopes string `json:"Scopes"`
OAuthAutoCreateUsers bool `json:"OAuthAutoCreateUsers"`
DefaultTeamID TeamID `json:"DefaultTeamID"`
DefaultTeamID TeamID `json:"DefaultTeamID"`
}
// TLSConfiguration represents a TLS configuration
@ -764,13 +764,6 @@ type (
GetUserGroups(username string, settings *LDAPSettings) ([]string, error)
}
// OAuthService represents a service used to authenticate users against an authorization server
OAuthService interface {
GetAccessToken(code string, settings *OAuthSettings) (string, error)
GetUsername(token string, settings *OAuthSettings) (string, error)
BuildLoginURL(oauthSettings *OAuthSettings) string
}
// SwarmStackManager represents a service to manage Swarm stacks
SwarmStackManager interface {
Login(dockerhub *DockerHub, registries []Registry, endpoint *Endpoint)
@ -809,7 +802,8 @@ const (
// MessageOfTheDayURL represents the URL where Portainer MOTD message can be retrieved
MessageOfTheDayURL = AssetsServerURL + "/motd.html"
// ExtensionDefinitionsURL represents the URL where Portainer extension definitions can be retrieved
ExtensionDefinitionsURL = AssetsServerURL + "/extensions.json"
// TODO: UPDATE URL to production URL
ExtensionDefinitionsURL = AssetsServerURL + "/extensions-dev.json"
// PortainerAgentHeader represents the name of the header available in any agent response
PortainerAgentHeader = "Portainer-Agent"
// PortainerAgentTargetHeader represent the name of the header containing the target node name
@ -936,6 +930,8 @@ const (
_ ExtensionID = iota
// RegistryManagementExtension represents the registry management extension
RegistryManagementExtension
// OAuthAuthenticationExtension represents the OAuth authentication extension
OAuthAuthenticationExtension
)
const (

View File

@ -62,5 +62,20 @@ angular.module('portainer.app')
return deferred.promise;
};
service.OAuthAuthenticationEnabled = function() {
var deferred = $q.defer();
service.extensions(false)
.then(function onSuccess(extensions) {
var extensionAvailable = _.find(extensions, { Id: 2, Enabled: true }) ? true : false;
deferred.resolve(extensionAvailable);
})
.catch(function onError(err) {
deferred.reject(err);
});
return deferred.promise;
};
return service;
}]);

View File

@ -37,7 +37,7 @@
<p>LDAP authentication</p>
</label>
</div>
<div>
<div ng-if="oauthAuthenticationAvailable">
<input type="radio" id="registry_auth" ng-model="settings.AuthenticationMethod" ng-value="3">
<label for="registry_auth">
<div class="boxselector_header">
@ -47,6 +47,16 @@
<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

@ -1,6 +1,6 @@
angular.module('portainer.app')
.controller('SettingsAuthenticationController', ['$q', '$scope', 'Notifications', 'SettingsService', 'FileUploadService', 'TeamService',
function ($q, $scope, Notifications, SettingsService, FileUploadService, TeamService) {
.controller('SettingsAuthenticationController', ['$q', '$scope', '$state', 'Notifications', 'SettingsService', 'FileUploadService', 'TeamService', 'ExtensionService',
function($q, $scope, $state, Notifications, SettingsService, FileUploadService, TeamService, ExtensionService) {
$scope.state = {
successfulConnectivityCheck: false,
@ -14,6 +14,10 @@ function ($q, $scope, Notifications, SettingsService, FileUploadService, TeamSer
TLSCACert: ''
};
$scope.goToOAuthExtensionView = function() {
$state.go('portainer.extensions.extension', { id: 2 });
};
$scope.isOauthEnabled = function isOauthEnabled() {
return $scope.settings && $scope.settings.AuthenticationMethod === 3;
};
@ -25,7 +29,7 @@ function ($q, $scope, Notifications, SettingsService, FileUploadService, TeamSer
$scope.removeSearchConfiguration = function(index) {
$scope.LDAPSettings.SearchSettings.splice(index, 1);
};
$scope.addGroupSearchConfiguration = function() {
$scope.LDAPSettings.GroupSearchSettings.push({ GroupBaseDN: '', GroupAttribute: '', GroupFilter: '' });
};
@ -98,14 +102,17 @@ function ($q, $scope, Notifications, SettingsService, FileUploadService, TeamSer
function initView() {
$q.all({
settings: SettingsService.settings(),
teams: TeamService.teams()
}).then(function success(data) {
teams: TeamService.teams(),
oauthAuthentication: ExtensionService.OAuthAuthenticationEnabled()
})
.then(function success(data) {
var settings = data.settings;
$scope.teams = data.teams;
$scope.settings = settings;
$scope.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');