From bd5c83a8cb46eb6da2b25df3b1f6a2a5795d1869 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Wed, 20 Jul 2016 16:24:18 +0200 Subject: [PATCH] Client: Add authHttp service that authentificates the http request and optionally refresh the access token if needed --- client/src/app/app.component.ts | 13 ++- client/src/app/friends/friend.service.ts | 12 +- client/src/app/login/login.component.ts | 9 +- .../src/app/shared/auth/auth-http.service.ts | 77 +++++++++++++ .../{users => auth}/auth-status.model.ts | 0 .../shared/{users => auth}/auth.service.ts | 83 ++++++++++++-- .../src/app/shared/{users => auth}/index.ts | 2 +- client/src/app/shared/auth/user.model.ts | 103 ++++++++++++++++++ client/src/app/shared/index.ts | 2 +- client/src/app/shared/users/token.model.ts | 32 ------ client/src/app/shared/users/user.model.ts | 20 ---- client/src/app/videos/shared/video.service.ts | 10 +- .../videos/video-add/video-add.component.ts | 18 ++- client/src/main.ts | 20 +++- client/tsconfig.json | 10 +- 15 files changed, 314 insertions(+), 97 deletions(-) create mode 100644 client/src/app/shared/auth/auth-http.service.ts rename client/src/app/shared/{users => auth}/auth-status.model.ts (100%) rename client/src/app/shared/{users => auth}/auth.service.ts (55%) rename client/src/app/shared/{users => auth}/index.ts (72%) create mode 100644 client/src/app/shared/auth/user.model.ts delete mode 100644 client/src/app/shared/users/token.model.ts delete mode 100644 client/src/app/shared/users/user.model.ts diff --git a/client/src/app/app.component.ts b/client/src/app/app.component.ts index 354d00a7a..f53896bcf 100644 --- a/client/src/app/app.component.ts +++ b/client/src/app/app.component.ts @@ -1,5 +1,4 @@ import { Component } from '@angular/core'; -import { HTTP_PROVIDERS } from '@angular/http'; import { ActivatedRoute, Router, ROUTER_DIRECTIVES } from '@angular/router'; import { FriendService } from './friends'; @@ -16,7 +15,7 @@ import { VideoService } from './videos'; template: require('./app.component.html'), styles: [ require('./app.component.scss') ], directives: [ ROUTER_DIRECTIVES, SearchComponent ], - providers: [ AuthService, FriendService, HTTP_PROVIDERS, VideoService, SearchService ] + providers: [ FriendService, VideoService, SearchService ] }) export class AppComponent { @@ -35,14 +34,20 @@ export class AppComponent { status => { if (status === AuthStatus.LoggedIn) { this.isLoggedIn = true; + console.log('Logged in.'); + } else if (status === AuthStatus.LoggedOut) { + this.isLoggedIn = false; + console.log('Logged out.'); + } else { + console.error('Unknown auth status: ' + status); } } ); } - // FIXME logout() { - // this._authService.logout(); + this.authService.logout(); + this.authService.setStatus(AuthStatus.LoggedOut); } makeFriends() { diff --git a/client/src/app/friends/friend.service.ts b/client/src/app/friends/friend.service.ts index f956a5ece..771046484 100644 --- a/client/src/app/friends/friend.service.ts +++ b/client/src/app/friends/friend.service.ts @@ -1,25 +1,23 @@ import { Injectable } from '@angular/core'; -import { Http, Response } from '@angular/http'; +import { Response } from '@angular/http'; import { Observable } from 'rxjs/Observable'; -import { AuthService } from '../shared'; +import { AuthHttp, AuthService } from '../shared'; @Injectable() export class FriendService { private static BASE_FRIEND_URL: string = '/api/v1/pods/'; - constructor (private http: Http, private authService: AuthService) {} + constructor (private authHttp: AuthHttp, private authService: AuthService) {} makeFriends() { - const headers = this.authService.getRequestHeader(); - return this.http.get(FriendService.BASE_FRIEND_URL + 'makefriends', { headers }) + return this.authHttp.get(FriendService.BASE_FRIEND_URL + 'makefriends') .map(res => res.status) .catch(this.handleError); } quitFriends() { - const headers = this.authService.getRequestHeader(); - return this.http.get(FriendService.BASE_FRIEND_URL + 'quitfriends', { headers }) + return this.authHttp.get(FriendService.BASE_FRIEND_URL + 'quitfriends') .map(res => res.status) .catch(this.handleError); } diff --git a/client/src/app/login/login.component.ts b/client/src/app/login/login.component.ts index bcfa021fa..ddd62462e 100644 --- a/client/src/app/login/login.component.ts +++ b/client/src/app/login/login.component.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; import { Router } from '@angular/router'; -import { AuthService, AuthStatus, User } from '../shared'; +import { AuthService } from '../shared'; @Component({ selector: 'my-login', @@ -21,14 +21,11 @@ export class LoginComponent { result => { this.error = null; - const user = new User(username, result); - user.save(); - - this.authService.setStatus(AuthStatus.LoggedIn); - this.router.navigate(['/videos/list']); }, error => { + console.error(error); + if (error.error === 'invalid_grant') { this.error = 'Credentials are invalid.'; } else { diff --git a/client/src/app/shared/auth/auth-http.service.ts b/client/src/app/shared/auth/auth-http.service.ts new file mode 100644 index 000000000..ff8099a46 --- /dev/null +++ b/client/src/app/shared/auth/auth-http.service.ts @@ -0,0 +1,77 @@ +import { Injectable } from '@angular/core'; +import { + ConnectionBackend, + Headers, + Http, + Request, + RequestMethod, + RequestOptions, + RequestOptionsArgs, + Response +} from '@angular/http'; +import { Observable } from 'rxjs/Observable'; + +import { AuthService } from './auth.service'; + +@Injectable() +export class AuthHttp extends Http { + constructor(backend: ConnectionBackend, defaultOptions: RequestOptions, private authService: AuthService) { + super(backend, defaultOptions); + } + + request(url: string | Request, options?: RequestOptionsArgs): Observable { + if (!options) options = {}; + + options.headers = new Headers(); + this.setAuthorizationHeader(options.headers); + + return super.request(url, options) + .catch((err) => { + if (err.status === 401) { + return this.handleTokenExpired(err, url, options); + } + + return Observable.throw(err); + }); + } + + delete(url: string, options?: RequestOptionsArgs): Observable { + if (!options) options = {}; + options.method = RequestMethod.Delete; + + return this.request(url, options); + } + + get(url: string, options?: RequestOptionsArgs): Observable { + if (!options) options = {}; + options.method = RequestMethod.Get; + + return this.request(url, options); + } + + post(url: string, options?: RequestOptionsArgs): Observable { + if (!options) options = {}; + options.method = RequestMethod.Post; + + return this.request(url, options); + } + + put(url: string, options?: RequestOptionsArgs): Observable { + if (!options) options = {}; + options.method = RequestMethod.Put; + + return this.request(url, options); + } + + private handleTokenExpired(err: Response, url: string | Request, options: RequestOptionsArgs) { + return this.authService.refreshAccessToken().flatMap(() => { + this.setAuthorizationHeader(options.headers); + + return super.request(url, options); + }); + } + + private setAuthorizationHeader(headers: Headers) { + headers.set('Authorization', `${this.authService.getTokenType()} ${this.authService.getToken()}`); + } +} diff --git a/client/src/app/shared/users/auth-status.model.ts b/client/src/app/shared/auth/auth-status.model.ts similarity index 100% rename from client/src/app/shared/users/auth-status.model.ts rename to client/src/app/shared/auth/auth-status.model.ts diff --git a/client/src/app/shared/users/auth.service.ts b/client/src/app/shared/auth/auth.service.ts similarity index 55% rename from client/src/app/shared/users/auth.service.ts rename to client/src/app/shared/auth/auth.service.ts index 1c822c1e1..47f7e1368 100644 --- a/client/src/app/shared/users/auth.service.ts +++ b/client/src/app/shared/auth/auth.service.ts @@ -9,13 +9,14 @@ import { User } from './user.model'; @Injectable() export class AuthService { private static BASE_CLIENT_URL = '/api/v1/users/client'; - private static BASE_LOGIN_URL = '/api/v1/users/token'; + private static BASE_TOKEN_URL = '/api/v1/users/token'; loginChangedSource: Observable; private clientId: string; private clientSecret: string; private loginChanged: Subject; + private user: User = null; constructor(private http: Http) { this.loginChanged = new Subject(); @@ -36,12 +37,21 @@ export class AuthService { alert(error); } ); + + // Return null if there is nothing to load + this.user = User.load(); } getAuthRequestOptions(): RequestOptions { return new RequestOptions({ headers: this.getRequestHeader() }); } + getRefreshToken() { + if (this.user === null) return null; + + return this.user.getRefreshToken(); + } + getRequestHeader() { return new Headers({ 'Authorization': this.getRequestHeaderValue() }); } @@ -51,21 +61,19 @@ export class AuthService { } getToken() { - return localStorage.getItem('access_token'); + if (this.user === null) return null; + + return this.user.getAccessToken(); } getTokenType() { - return localStorage.getItem('token_type'); + if (this.user === null) return null; + + return this.user.getTokenType(); } getUser(): User { - if (this.isLoggedIn() === false) { - return null; - } - - const user = User.load(); - - return user; + return this.user; } isLoggedIn() { @@ -93,21 +101,72 @@ export class AuthService { headers: headers }; - return this.http.post(AuthService.BASE_LOGIN_URL, body.toString(), options) + return this.http.post(AuthService.BASE_TOKEN_URL, body.toString(), options) .map(res => res.json()) + .map(res => { + res.username = username; + return res; + }) + .map(res => this.handleLogin(res)) .catch(this.handleError); } logout() { - // TODO make HTTP request + // TODO: make an HTTP request to revoke the tokens + this.user = null; + User.flush(); + } + + refreshAccessToken() { + console.log('Refreshing token...'); + + const refreshToken = this.getRefreshToken(); + + let body = new URLSearchParams(); + body.set('refresh_token', refreshToken); + body.set('client_id', this.clientId); + body.set('client_secret', this.clientSecret); + body.set('response_type', 'code'); + body.set('grant_type', 'refresh_token'); + + let headers = new Headers(); + headers.append('Content-Type', 'application/x-www-form-urlencoded'); + + let options = { + headers: headers + }; + + return this.http.post(AuthService.BASE_TOKEN_URL, body.toString(), options) + .map(res => res.json()) + .map(res => this.handleRefreshToken(res)) + .catch(this.handleError); } setStatus(status: AuthStatus) { this.loginChanged.next(status); } + private handleLogin (obj: any) { + const username = obj.username; + const hash_tokens = { + access_token: obj.access_token, + token_type: obj.token_type, + refresh_token: obj.refresh_token + }; + + this.user = new User(username, hash_tokens); + this.user.save(); + + this.setStatus(AuthStatus.LoggedIn); + } + private handleError (error: Response) { console.error(error); return Observable.throw(error.json() || { error: 'Server error' }); } + + private handleRefreshToken (obj: any) { + this.user.refreshTokens(obj.access_token, obj.refresh_token); + this.user.save(); + } } diff --git a/client/src/app/shared/users/index.ts b/client/src/app/shared/auth/index.ts similarity index 72% rename from client/src/app/shared/users/index.ts rename to client/src/app/shared/auth/index.ts index c6816b3c6..aafaacbf1 100644 --- a/client/src/app/shared/users/index.ts +++ b/client/src/app/shared/auth/index.ts @@ -1,4 +1,4 @@ +export * from './auth-http.service'; export * from './auth-status.model'; export * from './auth.service'; -export * from './token.model'; export * from './user.model'; diff --git a/client/src/app/shared/auth/user.model.ts b/client/src/app/shared/auth/user.model.ts new file mode 100644 index 000000000..98852f835 --- /dev/null +++ b/client/src/app/shared/auth/user.model.ts @@ -0,0 +1,103 @@ +export class User { + private static KEYS = { + USERNAME: 'username' + }; + + username: string; + tokens: Tokens; + + static load() { + const usernameLocalStorage = localStorage.getItem(this.KEYS.USERNAME); + if (usernameLocalStorage) { + return new User(localStorage.getItem(this.KEYS.USERNAME), Tokens.load()); + } + + return null; + } + + static flush() { + localStorage.removeItem(this.KEYS.USERNAME); + Tokens.flush(); + } + + constructor(username: string, hash_tokens: any) { + this.username = username; + this.tokens = new Tokens(hash_tokens); + } + + getAccessToken() { + return this.tokens.access_token; + } + + getRefreshToken() { + return this.tokens.refresh_token; + } + + getTokenType() { + return this.tokens.token_type; + } + + refreshTokens(access_token: string, refresh_token: string) { + this.tokens.access_token = access_token; + this.tokens.refresh_token = refresh_token; + } + + save() { + localStorage.setItem('username', this.username); + this.tokens.save(); + } +} + +// Private class used only by User +class Tokens { + private static KEYS = { + ACCESS_TOKEN: 'access_token', + REFRESH_TOKEN: 'refresh_token', + TOKEN_TYPE: 'token_type', + }; + + access_token: string; + refresh_token: string; + token_type: string; + + static load() { + const accessTokenLocalStorage = localStorage.getItem(this.KEYS.ACCESS_TOKEN); + const refreshTokenLocalStorage = localStorage.getItem(this.KEYS.REFRESH_TOKEN); + const tokenTypeLocalStorage = localStorage.getItem(this.KEYS.TOKEN_TYPE); + + if (accessTokenLocalStorage && refreshTokenLocalStorage && tokenTypeLocalStorage) { + return new Tokens({ + access_token: accessTokenLocalStorage, + refresh_token: refreshTokenLocalStorage, + token_type: tokenTypeLocalStorage + }); + } + + return null; + } + + static flush() { + localStorage.removeItem(this.KEYS.ACCESS_TOKEN); + localStorage.removeItem(this.KEYS.REFRESH_TOKEN); + localStorage.removeItem(this.KEYS.TOKEN_TYPE); + } + + constructor(hash?: any) { + if (hash) { + this.access_token = hash.access_token; + this.refresh_token = hash.refresh_token; + + if (hash.token_type === 'bearer') { + this.token_type = 'Bearer'; + } else { + this.token_type = hash.token_type; + } + } + } + + save() { + localStorage.setItem('access_token', this.access_token); + localStorage.setItem('refresh_token', this.refresh_token); + localStorage.setItem('token_type', this.token_type); + } +} diff --git a/client/src/app/shared/index.ts b/client/src/app/shared/index.ts index 0cab7dad0..dfea4c67c 100644 --- a/client/src/app/shared/index.ts +++ b/client/src/app/shared/index.ts @@ -1,2 +1,2 @@ +export * from './auth'; export * from './search'; -export * from './users' diff --git a/client/src/app/shared/users/token.model.ts b/client/src/app/shared/users/token.model.ts deleted file mode 100644 index 021c83fad..000000000 --- a/client/src/app/shared/users/token.model.ts +++ /dev/null @@ -1,32 +0,0 @@ -export class Token { - access_token: string; - refresh_token: string; - token_type: string; - - static load() { - return new Token({ - access_token: localStorage.getItem('access_token'), - refresh_token: localStorage.getItem('refresh_token'), - token_type: localStorage.getItem('token_type') - }); - } - - constructor(hash?: any) { - if (hash) { - this.access_token = hash.access_token; - this.refresh_token = hash.refresh_token; - - if (hash.token_type === 'bearer') { - this.token_type = 'Bearer'; - } else { - this.token_type = hash.token_type; - } - } - } - - save() { - localStorage.setItem('access_token', this.access_token); - localStorage.setItem('refresh_token', this.refresh_token); - localStorage.setItem('token_type', this.token_type); - } -} diff --git a/client/src/app/shared/users/user.model.ts b/client/src/app/shared/users/user.model.ts deleted file mode 100644 index ca0a5f26c..000000000 --- a/client/src/app/shared/users/user.model.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Token } from './token.model'; - -export class User { - username: string; - token: Token; - - static load() { - return new User(localStorage.getItem('username'), Token.load()); - } - - constructor(username: string, hash_token: any) { - this.username = username; - this.token = new Token(hash_token); - } - - save() { - localStorage.setItem('username', this.username); - this.token.save(); - } -} diff --git a/client/src/app/videos/shared/video.service.ts b/client/src/app/videos/shared/video.service.ts index dcbef7717..b4396f767 100644 --- a/client/src/app/videos/shared/video.service.ts +++ b/client/src/app/videos/shared/video.service.ts @@ -5,7 +5,7 @@ import { Observable } from 'rxjs/Observable'; import { Pagination } from './pagination.model'; import { Search } from '../../shared'; import { SortField } from './sort-field.type'; -import { AuthService } from '../../shared'; +import { AuthHttp, AuthService } from '../../shared'; import { Video } from './video.model'; @Injectable() @@ -14,6 +14,7 @@ export class VideoService { constructor( private authService: AuthService, + private authHttp: AuthHttp, private http: Http ) {} @@ -35,10 +36,9 @@ export class VideoService { } removeVideo(id: string) { - const options = this.authService.getAuthRequestOptions(); - return this.http.delete(VideoService.BASE_VIDEO_URL + id, options) - .map(res => res.status) - .catch(this.handleError); + return this.authHttp.delete(VideoService.BASE_VIDEO_URL + id) + .map(res => res.status) + .catch(this.handleError); } searchVideos(search: Search, pagination: Pagination, sort: SortField) { diff --git a/client/src/app/videos/video-add/video-add.component.ts b/client/src/app/videos/video-add/video-add.component.ts index 342935e36..c0f8cb9c4 100644 --- a/client/src/app/videos/video-add/video-add.component.ts +++ b/client/src/app/videos/video-add/video-add.component.ts @@ -130,8 +130,22 @@ export class VideoAddComponent implements OnInit { }; item.onError = (response: string, status: number) => { - this.error = (status === 400) ? response : 'Unknow error'; - console.error(this.error); + // We need to handle manually these cases beceause we use the FileUpload component + if (status === 400) { + this.error = response; + } else if (status === 401) { + this.error = 'Access token was expired, refreshing token...'; + this.authService.refreshAccessToken().subscribe( + () => { + // Update the uploader request header + this.uploader.authToken = this.authService.getRequestHeaderValue(); + this.error += ' access token refreshed. Please retry your request.'; + } + ); + } else { + this.error = 'Unknow error'; + console.error(this.error); + } }; diff --git a/client/src/main.ts b/client/src/main.ts index f9c1d50b8..a78d275ad 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -1,12 +1,28 @@ -import { enableProdMode } from '@angular/core'; +import { enableProdMode, provide } from '@angular/core'; +import { + HTTP_PROVIDERS, + RequestOptions, + XHRBackend +} from '@angular/http'; import { bootstrap } from '@angular/platform-browser-dynamic'; import { provideRouter } from '@angular/router'; import { AppComponent } from './app/app.component'; import { routes } from './app/app.routes'; +import { AuthHttp, AuthService } from './app/shared'; if (process.env.ENV === 'production') { enableProdMode(); } -bootstrap(AppComponent, [ provideRouter(routes) ]); +bootstrap(AppComponent, [ + HTTP_PROVIDERS, + provide(AuthHttp, { + useFactory: (backend: XHRBackend, defaultOptions: RequestOptions, authService: AuthService) => { + return new AuthHttp(backend, defaultOptions, authService); + }, + deps: [ XHRBackend, RequestOptions, AuthService ] + }), + AuthService, + provideRouter(routes) +]); diff --git a/client/tsconfig.json b/client/tsconfig.json index c7f61902c..67d1fb4f1 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -34,17 +34,17 @@ "src/app/login/index.ts", "src/app/login/login.component.ts", "src/app/login/login.routes.ts", + "src/app/shared/auth/auth-http.service.ts", + "src/app/shared/auth/auth-status.model.ts", + "src/app/shared/auth/auth.service.ts", + "src/app/shared/auth/index.ts", + "src/app/shared/auth/user.model.ts", "src/app/shared/index.ts", "src/app/shared/search/index.ts", "src/app/shared/search/search-field.type.ts", "src/app/shared/search/search.component.ts", "src/app/shared/search/search.model.ts", "src/app/shared/search/search.service.ts", - "src/app/shared/users/auth-status.model.ts", - "src/app/shared/users/auth.service.ts", - "src/app/shared/users/index.ts", - "src/app/shared/users/token.model.ts", - "src/app/shared/users/user.model.ts", "src/app/videos/index.ts", "src/app/videos/shared/index.ts", "src/app/videos/shared/loader/index.ts", -- 2.41.0