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

View File

@ -18,7 +18,8 @@ import (
var extensionDownloadBaseURL = "https://portainer-io-assets.sfo2.digitaloceanspaces.com/extensions/" var extensionDownloadBaseURL = "https://portainer-io-assets.sfo2.digitaloceanspaces.com/extensions/"
var extensionBinaryMap = map[portainer.ExtensionID]string{ 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 // ExtensionManager represents a service used to

View File

@ -1,7 +1,8 @@
package auth package auth
import ( import (
"log" "encoding/json"
"io/ioutil"
"net/http" "net/http"
"github.com/asaskevich/govalidator" "github.com/asaskevich/govalidator"
@ -21,6 +22,54 @@ func (payload *oauthPayload) Validate(r *http.Request) error {
return nil 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 { func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload oauthPayload var payload oauthPayload
err := request.DecodeAndValidateJSONPayload(r, &payload) 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} return &httperror.HandlerError{http.StatusForbidden, "OAuth authentication is not enabled", err}
} }
token, err := handler.OAuthService.GetAccessToken(payload.Code, &settings.OAuthSettings) extension, err := handler.ExtensionService.Extension(portainer.OAuthAuthenticationExtension)
if err != nil { if err == portainer.ErrObjectNotFound {
log.Printf("[DEBUG] - Failed retrieving access token: %v", err) return &httperror.HandlerError{http.StatusNotFound, "Oauth authentication extension is not enabled", err}
return &httperror.HandlerError{http.StatusUnprocessableEntity, "Invalid access token", portainer.ErrUnauthorized} } 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 { if err != nil {
log.Printf("[DEBUG] - Failed acquiring username: %v", err) return &httperror.HandlerError{http.StatusInternalServerError, "Unable to authenticate through OAuth", portainer.ErrUnauthorized}
return &httperror.HandlerError{http.StatusForbidden, "Unable to acquire username", portainer.ErrUnauthorized}
} }
user, err := handler.UserService.UserByUsername(username) 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) 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" "github.com/gorilla/mux"
httperror "github.com/portainer/libhttp/error" httperror "github.com/portainer/libhttp/error"
"github.com/portainer/portainer" "github.com/portainer/portainer"
"github.com/portainer/portainer/http/proxy"
"github.com/portainer/portainer/http/security" "github.com/portainer/portainer/http/security"
) )
@ -25,10 +26,11 @@ type Handler struct {
CryptoService portainer.CryptoService CryptoService portainer.CryptoService
JWTService portainer.JWTService JWTService portainer.JWTService
LDAPService portainer.LDAPService LDAPService portainer.LDAPService
OAuthService portainer.OAuthService
SettingsService portainer.SettingsService SettingsService portainer.SettingsService
TeamService portainer.TeamService TeamService portainer.TeamService
TeamMembershipService portainer.TeamMembershipService TeamMembershipService portainer.TeamMembershipService
ExtensionService portainer.ExtensionService
ProxyManager *proxy.Manager
} }
// NewHandler creates a handler to manage authentication operations. // NewHandler creates a handler to manage authentication operations.
@ -38,8 +40,6 @@ func NewHandler(bouncer *security.RequestBouncer, rateLimiter *security.RateLimi
authDisabled: authDisabled, authDisabled: authDisabled,
} }
h.Handle("/auth/oauth/login",
rateLimiter.LimitAccess(bouncer.PublicAccess(httperror.LoggerHandler(h.loginOAuth)))).Methods(http.MethodGet)
h.Handle("/auth/oauth/validate", h.Handle("/auth/oauth/validate",
rateLimiter.LimitAccess(bouncer.PublicAccess(httperror.LoggerHandler(h.validateOAuth)))).Methods(http.MethodPost) rateLimiter.LimitAccess(bouncer.PublicAccess(httperror.LoggerHandler(h.validateOAuth)))).Methods(http.MethodPost)
h.Handle("/auth", h.Handle("/auth",

View File

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

View File

@ -33,6 +33,7 @@ func (handler *Handler) settingsPublic(w http.ResponseWriter, r *http.Request) *
AllowPrivilegedModeForRegularUsers: settings.AllowPrivilegedModeForRegularUsers, AllowPrivilegedModeForRegularUsers: settings.AllowPrivilegedModeForRegularUsers,
EnableHostManagementFeatures: settings.EnableHostManagementFeatures, EnableHostManagementFeatures: settings.EnableHostManagementFeatures,
ExternalTemplates: false, 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", OAuthLoginURI: fmt.Sprintf("%s?response_type=code&client_id=%s&redirect_uri=%s&scope=%s&state=portainer",
settings.OAuthSettings.AuthorizationURI, settings.OAuthSettings.AuthorizationURI,
settings.OAuthSettings.ClientID, settings.OAuthSettings.ClientID,

View File

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

View File

@ -55,7 +55,6 @@ type Server struct {
GitService portainer.GitService GitService portainer.GitService
JWTService portainer.JWTService JWTService portainer.JWTService
LDAPService portainer.LDAPService LDAPService portainer.LDAPService
OAuthService portainer.OAuthService
ExtensionService portainer.ExtensionService ExtensionService portainer.ExtensionService
RegistryService portainer.RegistryService RegistryService portainer.RegistryService
ResourceControlService portainer.ResourceControlService ResourceControlService portainer.ResourceControlService
@ -105,10 +104,11 @@ func (server *Server) Start() error {
authHandler.CryptoService = server.CryptoService authHandler.CryptoService = server.CryptoService
authHandler.JWTService = server.JWTService authHandler.JWTService = server.JWTService
authHandler.LDAPService = server.LDAPService authHandler.LDAPService = server.LDAPService
authHandler.OAuthService = server.OAuthService
authHandler.SettingsService = server.SettingsService authHandler.SettingsService = server.SettingsService
authHandler.TeamService = server.TeamService authHandler.TeamService = server.TeamService
authHandler.TeamMembershipService = server.TeamMembershipService authHandler.TeamMembershipService = server.TeamMembershipService
authHandler.ExtensionService = server.ExtensionService
authHandler.ProxyManager = proxyManager
var dockerHubHandler = dockerhub.NewHandler(requestBouncer) var dockerHubHandler = dockerhub.NewHandler(requestBouncer)
dockerHubHandler.DockerHubService = server.DockerHubService dockerHubHandler.DockerHubService = server.DockerHubService
@ -157,7 +157,6 @@ func (server *Server) Start() error {
var settingsHandler = settings.NewHandler(requestBouncer) var settingsHandler = settings.NewHandler(requestBouncer)
settingsHandler.SettingsService = server.SettingsService settingsHandler.SettingsService = server.SettingsService
settingsHandler.LDAPService = server.LDAPService settingsHandler.LDAPService = server.LDAPService
settingsHandler.OAuthService = server.OAuthService
settingsHandler.FileService = server.FileService settingsHandler.FileService = server.FileService
settingsHandler.JobScheduler = server.JobScheduler settingsHandler.JobScheduler = server.JobScheduler
settingsHandler.ScheduleService = server.ScheduleService 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"` UserIdentifier string `json:"UserIdentifier"`
Scopes string `json:"Scopes"` Scopes string `json:"Scopes"`
OAuthAutoCreateUsers bool `json:"OAuthAutoCreateUsers"` OAuthAutoCreateUsers bool `json:"OAuthAutoCreateUsers"`
DefaultTeamID TeamID `json:"DefaultTeamID"` DefaultTeamID TeamID `json:"DefaultTeamID"`
} }
// TLSConfiguration represents a TLS configuration // TLSConfiguration represents a TLS configuration
@ -764,13 +764,6 @@ type (
GetUserGroups(username string, settings *LDAPSettings) ([]string, error) 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 represents a service to manage Swarm stacks
SwarmStackManager interface { SwarmStackManager interface {
Login(dockerhub *DockerHub, registries []Registry, endpoint *Endpoint) Login(dockerhub *DockerHub, registries []Registry, endpoint *Endpoint)
@ -809,7 +802,8 @@ const (
// MessageOfTheDayURL represents the URL where Portainer MOTD message can be retrieved // MessageOfTheDayURL represents the URL where Portainer MOTD message can be retrieved
MessageOfTheDayURL = AssetsServerURL + "/motd.html" MessageOfTheDayURL = AssetsServerURL + "/motd.html"
// ExtensionDefinitionsURL represents the URL where Portainer extension definitions can be retrieved // 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 represents the name of the header available in any agent response
PortainerAgentHeader = "Portainer-Agent" PortainerAgentHeader = "Portainer-Agent"
// PortainerAgentTargetHeader represent the name of the header containing the target node name // PortainerAgentTargetHeader represent the name of the header containing the target node name
@ -936,6 +930,8 @@ const (
_ ExtensionID = iota _ ExtensionID = iota
// RegistryManagementExtension represents the registry management extension // RegistryManagementExtension represents the registry management extension
RegistryManagementExtension RegistryManagementExtension
// OAuthAuthenticationExtension represents the OAuth authentication extension
OAuthAuthenticationExtension
) )
const ( const (

View File

@ -62,5 +62,20 @@ angular.module('portainer.app')
return deferred.promise; 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; return service;
}]); }]);

View File

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

View File

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