External apps authentication

stable
Alejandro Alonso 2015-08-27 15:06:31 +02:00 committed by Juanfran
parent 6b837d2067
commit f52a4ba788
23 changed files with 524 additions and 20 deletions

View File

@ -10,6 +10,7 @@
- Ability to choose a theme (thanks to [@astagi](https://github.com/astagi))
- Inline viewing of image attachments (thanks to [@brettp](https://github.com/brettp)).
- Autocomplete for usernames, user stories, tasks, issues, and wiki pages in text areas (thanks to [@brettp](https://github.com/brettp)).
- Support authentication via Application Tokens
- i18n.
- Add polish (pl) translation.
- Add portuguese (Brazil) (pt_BR) translation.

View File

@ -68,7 +68,6 @@ configure = ($routeProvider, $locationProvider, $httpProvider, $provide, $tgEven
},
title: "HOME.PAGE_TITLE",
description: "HOME.PAGE_DESCRIPTION",
loader: true
}
)
@ -334,22 +333,25 @@ configure = ($routeProvider, $locationProvider, $httpProvider, $provide, $tgEven
$routeProvider.when("/login",
{
templateUrl: "auth/login.html",
title: "LOGIN.PAGE_TITLE"
description: "LOGIN.PAGE_DESCRIPTION"
title: "LOGIN.PAGE_TITLE",
description: "LOGIN.PAGE_DESCRIPTION",
disableHeader: true
}
)
$routeProvider.when("/register",
{
templateUrl: "auth/register.html",
title: "REGISTER.PAGE_TITLE",
description: "REGISTER.PAGE_DESCRIPTION"
description: "REGISTER.PAGE_DESCRIPTION",
disableHeader: true
}
)
$routeProvider.when("/forgot-password",
{
templateUrl: "auth/forgot-password.html",
title: "FORGOT_PASSWORD.PAGE_TITLE",
description: "FORGOT_PASSWORD.PAGE_DESCRIPTION"
description: "FORGOT_PASSWORD.PAGE_DESCRIPTION",
disableHeader: true
}
)
$routeProvider.when("/change-password",
@ -357,6 +359,7 @@ configure = ($routeProvider, $locationProvider, $httpProvider, $provide, $tgEven
templateUrl: "auth/change-password-from-recovery.html",
title: "CHANGE_PASSWORD.PAGE_TITLE",
description: "CHANGE_PASSWORD.PAGE_TITLE",
disableHeader: true
}
)
$routeProvider.when("/change-password/:token",
@ -364,13 +367,29 @@ configure = ($routeProvider, $locationProvider, $httpProvider, $provide, $tgEven
templateUrl: "auth/change-password-from-recovery.html",
title: "CHANGE_PASSWORD.PAGE_TITLE",
description: "CHANGE_PASSWORD.PAGE_TITLE",
disableHeader: true
}
)
$routeProvider.when("/invitation/:token",
{
templateUrl: "auth/invitation.html",
title: "INVITATION.PAGE_TITLE",
description: "INVITATION.PAGE_DESCRIPTION"
description: "INVITATION.PAGE_DESCRIPTION",
disableHeader: true,
access: {
requiresLogin: true
}
}
)
$routeProvider.when("/external-apps",
{
templateUrl: "external-apps/external-app.html",
title: "EXTERNAL_APP.PAGE_TITLE",
description: "EXTERNAL_APP.PAGE_DESCRIPTION",
controller: "ExternalApp",
controllerAs: "vm",
disableHeader: true,
mobileViewport: true
}
)
@ -411,8 +430,8 @@ configure = ($routeProvider, $locationProvider, $httpProvider, $provide, $tgEven
$location.path($navUrls.resolve("error"))
$location.replace()
else if response.status == 401
nextPath = $location.path()
$location.url($navUrls.resolve("login")).search("next=#{nextPath}")
nextUrl = encodeURIComponent($location.url())
$location.url($navUrls.resolve("login")).search("next=#{nextUrl}")
return $q.reject(response)
@ -537,7 +556,7 @@ i18nInit = (lang, $translate) ->
checksley.updateMessages('default', messages)
init = ($log, $rootscope, $auth, $events, $analytics, $translate, $location, $navUrls, appMetaService, projectService, loaderService) ->
init = ($log, $rootscope, $auth, $events, $analytics, $translate, $location, $navUrls, appMetaService, projectService, loaderService, navigationBarService) ->
$log.debug("Initialize application")
# Taiga Plugins
@ -590,6 +609,15 @@ init = ($log, $rootscope, $auth, $events, $analytics, $translate, $location, $na
description = $translate.instant(next.description or "")
appMetaService.setAll(title, description)
if next.mobileViewport
appMetaService.addMobileViewport()
else
appMetaService.removeMobileViewport()
if next.disableHeader
navigationBarService.disableHeader()
else
navigationBarService.enableHeader()
modules = [
# Main Global Modules
@ -625,6 +653,7 @@ modules = [
"taigaProfile",
"taigaHome",
"taigaUserTimeline",
"taigaExternalApps",
# template cache
"templates",
@ -664,5 +693,6 @@ module.run([
"tgAppMetaService",
"tgProjectService",
"tgLoader",
"tgNavigationBarService"
init
])

View File

@ -221,12 +221,12 @@ LoginDirective = ($auth, $confirm, $location, $config, $routeParams, $navUrls, $
link = ($scope, $el, $attrs) ->
onSuccess = (response) ->
if $routeParams['next'] and $routeParams['next'] != $navUrls.resolve("login")
nextUrl = $routeParams['next']
nextUrl = decodeURIComponent($routeParams['next'])
else
nextUrl = $navUrls.resolve("home")
$events.setupConnection()
$location.path(nextUrl)
$location.url(nextUrl)
onError = (response) ->
$confirm.notify("light-error", $translate.instant("LOGIN_FORM.ERROR_AUTH_INCORRECT"))

View File

@ -149,6 +149,10 @@ urls = {
# locales
"locales": "/locales"
# Application tokens
"applications": "/applications"
"application-tokens": "/application-tokens"
}
# Initialize api urls service

View File

@ -1268,5 +1268,13 @@
"BLOCKED": "{{username}} has blocked {{obj_name}}",
"UNBLOCKED": "{{username}} has unblocked {{obj_name}}",
"NEW_USER": "{{username}} has joined Taiga"
},
"EXTERNAL_APP": {
"PAGE_TITLE": "An external app requires authentication",
"PAGE_DESCRIPTION": "An external app requires authentication",
"AUTHORIZATION_REQUEST": "Authorize {{application}} to use your Taiga account?",
"LOGIN_WITH_ANOTHER_USER": "Login with another user",
"AUTHORIZE_APP": "Authorize app",
"CANCEL": "Cancel"
}
}

View File

@ -0,0 +1,58 @@
taiga = @.taiga
class ExternalAppController extends taiga.Controller
@.$inject = [
"$routeParams",
"tgExternalAppsService",
"$window",
"tgCurrentUserService",
"$location",
"$tgNavUrls",
"tgXhrErrorService",
"tgLoader"
]
constructor: (@routeParams, @externalAppsService, @window, @currentUserService,
@location, @navUrls, @xhrError, @loader) ->
@loader.start(false)
@._applicationId = @routeParams.application
@._state = @routeParams.state
@._getApplicationToken()
@._user = @currentUserService.getUser()
@._application = null
nextUrl = encodeURIComponent(@location.url())
loginUrl = @navUrls.resolve("login")
@.loginWithAnotherUserUrl = "#{loginUrl}?next=#{nextUrl}"
taiga.defineImmutableProperty @, "user", () => @._user
taiga.defineImmutableProperty @, "application", () => @._application
_redirect: (applicationToken) =>
nextUrl = applicationToken.get("next_url")
@window.open(nextUrl, "_self")
_getApplicationToken: =>
return @externalAppsService.getApplicationToken(@._applicationId, @._state)
.then (data) =>
@._application = data.get("application")
if data.get("auth_code")
@._redirect(data)
else
@loader.pageLoaded()
.catch (xhr) =>
return @xhrError.response(xhr)
cancel: () ->
@window.history.back()
createApplicationToken: =>
return @externalAppsService.authorizeApplicationToken(@._applicationId, @._state)
.then (data) =>
@._redirect(data)
.catch (xhr) =>
return @xhrError.response(xhr)
angular.module("taigaExternalApps").controller("ExternalApp", ExternalAppController)

View File

@ -0,0 +1,164 @@
describe "ExternalAppController", ->
provide = null
$controller = null
$rootScope = null
mocks = {}
_inject = (callback) ->
inject (_$controller_, _$rootScope_) ->
$rootScope = _$rootScope_
$controller = _$controller_
_mockRouteParams = () ->
mocks.routeParams = {}
provide.value "$routeParams", mocks.routeParams
_mockTgExternalAppsService = () ->
mocks.tgExternalAppsService = {
getApplicationToken: sinon.stub()
authorizeApplicationToken: sinon.stub()
}
provide.value "tgExternalAppsService", mocks.tgExternalAppsService
_mockWindow = () ->
mocks.window = {
open: sinon.stub()
history: {
back: sinon.stub()
}
}
provide.value "$window", mocks.window
_mockTgCurrentUserService = () ->
mocks.tgCurrentUserService = {
getUser: sinon.stub()
}
provide.value "tgCurrentUserService", mocks.tgCurrentUserService
_mockLocation = () ->
mocks.location = {
url: sinon.stub()
}
provide.value "$location", mocks.location
_mockTgNavUrls = () ->
mocks.tgNavUrls = {
resolve: sinon.stub()
}
provide.value "$tgNavUrls", mocks.tgNavUrls
_mockTgXhrErrorService = () ->
mocks.tgXhrErrorService = {
response: sinon.spy(),
notFound: sinon.spy()
}
provide.value "tgXhrErrorService", mocks.tgXhrErrorService
_mockTgLoader = () ->
mocks.tgLoader = {
start: sinon.stub(),
pageLoaded: sinon.stub()
}
provide.value "tgLoader", mocks.tgLoader
_mocks = () ->
module ($provide) ->
provide = $provide
_mockRouteParams()
_mockTgExternalAppsService()
_mockWindow()
_mockTgCurrentUserService()
_mockLocation()
_mockTgNavUrls()
_mockTgXhrErrorService()
_mockTgLoader()
return null
beforeEach ->
module "taigaExternalApps"
_mocks()
_inject()
it "not existing application", (done) ->
$scope = $rootScope.$new()
mocks.routeParams.application = 6
mocks.routeParams.state = "testing-state"
xhr = {
status: 404
}
mocks.tgExternalAppsService.getApplicationToken.withArgs(mocks.routeParams.application, mocks.routeParams.state).promise().reject(xhr)
ctrl = $controller("ExternalApp")
setTimeout ( ->
expect(mocks.tgLoader.start.withArgs(false)).to.be.calledOnce
expect(mocks.tgXhrErrorService.response.withArgs(xhr)).to.be.calledOnce
done()
)
it "existing application and existing token, automatically redirecting to next url", (done) ->
$scope = $rootScope.$new()
mocks.routeParams.application = 6
mocks.routeParams.state = "testing-state"
applicationToken = Immutable.fromJS({
auth_code: "testing-auth-code"
next_url: "http://next.url"
})
mocks.tgExternalAppsService.getApplicationToken.withArgs(mocks.routeParams.application, mocks.routeParams.state).promise().resolve(applicationToken)
ctrl = $controller("ExternalApp")
setTimeout ( ->
expect(mocks.tgLoader.start.withArgs(false)).to.be.calledOnce
expect(mocks.window.open.callCount).to.be.equal(1)
expect(mocks.window.open.calledWith("http://next.url")).to.be.true
done()
)
it "existing application and creating new token", (done) ->
$scope = $rootScope.$new()
mocks.routeParams.application = 6
mocks.routeParams.state = "testing-state"
applicationToken = Immutable.fromJS({})
mocks.tgExternalAppsService.getApplicationToken.withArgs(mocks.routeParams.application, mocks.routeParams.state).promise().resolve(applicationToken)
ctrl = $controller("ExternalApp")
applicationToken = Immutable.fromJS({
next_url: "http://next.url"
auth_code: "testing-auth-code"
})
mocks.tgExternalAppsService.authorizeApplicationToken.withArgs(mocks.routeParams.application, mocks.routeParams.state).promise().resolve(applicationToken)
ctrl.createApplicationToken()
setTimeout ( ->
expect(mocks.tgLoader.start.withArgs(false)).to.be.calledOnce
expect(mocks.tgLoader.pageLoaded).to.be.calledOnce
expect(mocks.window.open.callCount).to.be.equal(1)
expect(mocks.window.open.calledWith("http://next.url")).to.be.true
done()
)
it "cancel back to previous url", () ->
$scope = $rootScope.$new()
mocks.routeParams.application = 6
mocks.routeParams.state = "testing-state"
applicationToken = Immutable.fromJS({})
mocks.tgExternalAppsService.getApplicationToken.withArgs(mocks.routeParams.application, mocks.routeParams.state).promise().resolve(applicationToken)
ctrl = $controller("ExternalApp")
expect(mocks.window.history.back.callCount).to.be.equal(0)
ctrl.cancel()
expect(mocks.window.history.back.callCount).to.be.equal(1)

View File

@ -0,0 +1,28 @@
section.external-app-wrapper
div.logo
include ../../svg/logo-color.svg
h1 Taiga
h2(translate="EXTERNAL_APP.AUTHORIZATION_REQUEST", translate-values="{application: vm.application.get('name')}")
div.user-card.avatar
.card-inner
div.user-image
img(ng-src="{{::vm.user.get('photo')}}", alt="{{::vm.user.get('full_name_display')}}")
div.user-data
h3 {{ ::vm.user.get("full_name_display") }}
p {{ ::vm.user.get("email") }}
a(ng-href="{{::vm.loginWithAnotherUserUrl}}", title="{{'EXTERNAL_APP.LOGIN_WITH_ANOTHER_USER' | translate}}", translate="EXTERNAL_APP.LOGIN_WITH_ANOTHER_USER")
div.app-card
.card-inner
div.app-image
img(ng-src="{{::vm.application.get('icon_url')}}", alt="{{::vm.application.get('name')}}")
div.app-data
h3 {{ ::vm.application.get("name") }}
a(ng-href="{{::vm.application.get('web')}}", title="{{::vm.application.get('name')}}", target="_blank") {{ ::vm.application.get('web') }}
p {{ ::vm.application.get("description") }}
a.button-green(href="#", ng-click="vm.createApplicationToken()", title="{{'EXTERNAL_APP.AUTHORIZE_APP' | translate}}", translate="EXTERNAL_APP.AUTHORIZE_APP")
a.cancel(href="#", ng-click="vm.cancel()", title="{{'EXTERNAL_APP.CANCEL' | translate}}", translate="EXTERNAL_APP.CANCEL")

View File

@ -0,0 +1,85 @@
.external-app-wrapper {
margin: 2rem auto;
text-align: center;
width: 480px;
.logo {
height: 6rem;
margin: 0 auto;
width: 6rem;
}
h1 {
margin-bottom: 0;
}
.app-card,
.user-card {
line-height: 1.4;
margin-bottom: 2rem;
text-align: left;
.card-inner {
display: flex;
}
img {
width: 100%;
}
h3,
p {
margin: 0;
}
h3 {
@extend %large;
}
a {
@extend %xsmall;
display: block;
}
}
.app-card {
.app-image {
flex-basis: 100px;
margin-right: 1rem;
max-width: 105px;
}
.app-data {
flex: 1;
}
a {
margin-bottom: .5rem;
}
p {
@extend %xsmall;
}
}
.user-card {
background: $card;
border: 1px solid $card-hover;
padding: 1rem;
.card-inner {
margin-bottom: .5rem;
}
.user-image {
flex-basis: 50px;
margin-right: 1rem;
max-width: 55px;
}
}
.button-green {
display: block;
}
.cancel {
@extend %small;
display: block;
margin-top: .5rem;
text-align: left;
}
}
@include breakpoint(mobile) {
.external-app-wrapper {
margin: 0;
min-width: 100%;
padding: 2rem 1rem;
text-align: center;
width: 100%;
}
}

View File

@ -0,0 +1,14 @@
class ExternalAppsService extends taiga.Service
@.$inject = [
"tgResources"
]
constructor: (@rs) ->
getApplicationToken: (applicationId, state) ->
return @rs.externalapps.getApplicationToken(applicationId, state)
authorizeApplicationToken: (applicationId, state) ->
return @rs.externalapps.authorizeApplicationToken(applicationId, state)
angular.module("taigaExternalApps").service("tgExternalAppsService", ExternalAppsService)

View File

@ -0,0 +1,44 @@
describe "tgExternalAppsService", ->
externalAppsService = provide = null
mocks = {}
_mockTgResources = () ->
mocks.tgResources = {
externalapps: {
getApplicationToken: sinon.stub()
authorizeApplicationToken: sinon.stub()
}
}
provide.value "tgResources", mocks.tgResources
_inject = (callback) ->
inject (_tgExternalAppsService_) ->
externalAppsService = _tgExternalAppsService_
callback() if callback
_mocks = () ->
module ($provide) ->
provide = $provide
_mockTgResources()
return null
_setup = ->
_mocks()
beforeEach ->
module "taigaExternalApps"
_setup()
_inject()
it "getApplicationToken", () ->
expect(mocks.tgResources.externalapps.getApplicationToken.callCount).to.be.equal(0)
externalAppsService.getApplicationToken(6, "testing-state")
expect(mocks.tgResources.externalapps.getApplicationToken.callCount).to.be.equal(1)
expect(mocks.tgResources.externalapps.getApplicationToken.calledWith(6, "testing-state")).to.be.true
it "authorizeApplicationToken", () ->
expect(mocks.tgResources.externalapps.authorizeApplicationToken.callCount).to.be.equal(0)
externalAppsService.authorizeApplicationToken(6, "testing-state")
expect(mocks.tgResources.externalapps.authorizeApplicationToken.callCount).to.be.equal(1)
expect(mocks.tgResources.externalapps.authorizeApplicationToken.calledWith(6, "testing-state")).to.be.true

View File

@ -0,0 +1 @@
module = angular.module("taigaExternalApps", [])

View File

@ -1,4 +1,4 @@
NavigationBarDirective = (currentUserService, $location) ->
NavigationBarDirective = (currentUserService, navigationBarService, $location) ->
link = (scope, el, attrs, ctrl) ->
scope.vm = {}
@ -10,6 +10,8 @@ NavigationBarDirective = (currentUserService, $location) ->
taiga.defineImmutableProperty(scope.vm, "projects", () -> currentUserService.projects.get("recents"))
taiga.defineImmutableProperty(scope.vm, "isAuthenticated", () -> currentUserService.isAuthenticated())
taiga.defineImmutableProperty(scope.vm, "isEnabledHeader", () -> navigationBarService.isEnabledHeader())
directive = {
templateUrl: "navigation-bar/navigation-bar.html"
@ -21,6 +23,7 @@ NavigationBarDirective = (currentUserService, $location) ->
NavigationBarDirective.$inject = [
"tgCurrentUserService",
"tgNavigationBarService"
"$location"
]

View File

@ -1,4 +1,4 @@
nav.navbar
nav.navbar(ng-if="vm.isEnabledHeader")
div.nav-left
a.logo(
href="#",

View File

@ -0,0 +1,15 @@
class NavigationBarService extends taiga.Service
constructor: ->
@.disableHeader()
enableHeader: ->
@.enabledHeader = true
disableHeader: ->
@.enabledHeader = false
isEnabledHeader: ->
return @.enabledHeader
angular.module("taigaNavigationBar").service("tgNavigationBarService", NavigationBarService)

View File

@ -0,0 +1,27 @@
Resource = (urlsService, http) ->
service = {}
service.getApplicationToken = (applicationId, state) ->
url = urlsService.resolve("applications")
url = "#{url}/#{applicationId}/token?state=#{state}"
return http.get(url).then (result) ->
Immutable.fromJS(result.data)
service.authorizeApplicationToken = (applicationId, state) ->
url = urlsService.resolve("application-tokens")
url = "#{url}/authorize"
data = {
"state": state
"application": applicationId
}
return http.post(url, data).then (result) ->
Immutable.fromJS(result.data)
return () ->
return {"externalapps": service}
Resource.$inject = ["$tgUrls", "$tgHttp"]
module = angular.module("taigaResources2")
module.factory("tgExternalAppsResource", Resource)

View File

@ -3,7 +3,8 @@ services = [
"tgUsersResources",
"tgUserstoriesResource",
"tgTasksResource",
"tgIssuesResource"
"tgIssuesResource",
"tgExternalAppsResource"
]
Resources = ($injector) ->

View File

@ -59,5 +59,13 @@ class AppMetaService extends taiga.Service = ->
@.setTwitterMetas(title, description)
@.setOpenGraphMetas(title, description)
addMobileViewport: () ->
$('head').append(
'<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0">'
)
removeMobileViewport: () ->
$('meta[name="viewport"]').remove()
angular.module("taigaCommon").service("tgAppMetaService", AppMetaService)

View File

@ -1,6 +1,8 @@
doctype html
div
include ../includes/components/beta
div.wrapper
div.login-main
div.login-container

View File

@ -1,6 +1,6 @@
.beta {
left: 0;
position: absolute;
top: -40px;
top: 0;
z-index: 9999;
}

View File

@ -12,8 +12,7 @@
@else if $point == tablet {
@media (max-width: 767px) { @content ; }
}
@else if $point == mobileonly {
@else if $point == mobile {
@media (max-width: 480px) { @content ; }
}
}

12
app/svg/logo-color.svg Normal file
View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg preserveAspectRatio="xMidYMid meet" version="1.1" viewBox="0 0 267.204 267.245" xmlns:svg="http://www.w3.org/2000/svg">
<path d="M228.256 39.167l-11.755 82.74-82.74 11.756 11.756-82.74z" fill="#a295ae" opacity=".8"/>
<path d="M39.19 228.134l11.755-82.74 82.74-11.756-11.755 82.74z" fill="#5d6f6d" opacity=".8"/>
<path d="M39.19 39.277l82.74 11.755 11.756 82.74-82.74-11.755z" fill="#8cd592" opacity=".8"/>
<path d="M228.16 228.144l-82.74-11.755-11.756-82.742 82.74 11.756z" fill="#665e74" opacity=".8"/>
<path d="M133.738 267.245l-50.194-66.82 50.194-66.817 50.194 66.818z" fill="#3c3647" opacity=".8"/>
<path d="M267.204 133.48l-66.82 50.195-66.817-50.194 66.818-50.193z" fill="#837193" opacity=".8"/>
<path d="M133.616 0l50.194 66.82-50.194 66.817L83.422 66.82z" fill="#a2f4ac" opacity=".8"/>
<path d="M0 133.706l66.82-50.194 66.817 50.194L66.82 183.9z" fill="#7ea685" opacity=".8"/>
<path d="M133.602 101.85l31.772 31.772-31.772 31.772-31.772-31.772z" fill="#3c3647"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -1,6 +1,6 @@
<svg xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 134.2 134.3" version="1.1" preserveAspectRatio="xMidYMid meet">
<svg id="logo-svg" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 134.2 134.3" version="1.1" preserveAspectRatio="xMidYMid meet">
<style>
path {
#logo-svg path {
fill:#f5f5f5;
opacity:0.7;
}

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB