diff --git a/src/abstractions/api.service.ts b/src/abstractions/api.service.ts new file mode 100644 index 0000000000..9829f5abc2 --- /dev/null +++ b/src/abstractions/api.service.ts @@ -0,0 +1,37 @@ +import { EnvironmentUrls } from '../models/domain/environmentUrls'; + +import { CipherRequest } from '../models/request/cipherRequest'; +import { FolderRequest } from '../models/request/folderRequest'; +import { PasswordHintRequest } from '../models/request/passwordHintRequest'; +import { RegisterRequest } from '../models/request/registerRequest'; +import { TokenRequest } from '../models/request/tokenRequest'; +import { TwoFactorEmailRequest } from '../models/request/twoFactorEmailRequest'; + +import { CipherResponse } from '../models/response/cipherResponse'; +import { FolderResponse } from '../models/response/folderResponse'; +import { IdentityTokenResponse } from '../models/response/identityTokenResponse'; +import { SyncResponse } from '../models/response/syncResponse'; + +export interface ApiService { + urlsSet: boolean; + baseUrl: string; + identityBaseUrl: string; + deviceType: string; + logoutCallback: Function; + setUrls(urls: EnvironmentUrls); + postIdentityToken(request: TokenRequest): Promise; + refreshIdentityToken(): Promise; + postTwoFactorEmail(request: TwoFactorEmailRequest): Promise; + getAccountRevisionDate(): Promise; + postPasswordHint(request: PasswordHintRequest): Promise; + postRegister(request: RegisterRequest): Promise; + postFolder(request: FolderRequest): Promise; + putFolder(id: string, request: FolderRequest): Promise; + deleteFolder(id: string): Promise; + postCipher(request: CipherRequest): Promise; + putCipher(id: string, request: CipherRequest): Promise; + deleteCipher(id: string): Promise; + postCipherAttachment(id: string, data: FormData): Promise; + deleteCipherAttachment(id: string, attachmentId: string): Promise; + getSync(): Promise; +} diff --git a/src/abstractions/appId.service.ts b/src/abstractions/appId.service.ts new file mode 100644 index 0000000000..72d3b11948 --- /dev/null +++ b/src/abstractions/appId.service.ts @@ -0,0 +1,4 @@ +export interface AppIdService { + getAppId(): Promise; + getAnonymousAppId(): Promise; +} diff --git a/src/abstractions/index.ts b/src/abstractions/index.ts index 6191317b07..b3d6ecb527 100644 --- a/src/abstractions/index.ts +++ b/src/abstractions/index.ts @@ -1,5 +1,8 @@ +export { ApiService } from './api.service'; +export { AppIdService } from './appId.service'; export { CryptoService } from './crypto.service'; export { MessagingService } from './messaging.service'; export { PlatformUtilsService } from './platformUtils.service'; export { StorageService } from './storage.service'; +export { TokenService } from './token.service'; export { UtilsService } from './utils.service'; diff --git a/src/abstractions/token.service.ts b/src/abstractions/token.service.ts new file mode 100644 index 0000000000..03f2434c9a --- /dev/null +++ b/src/abstractions/token.service.ts @@ -0,0 +1,23 @@ +export interface TokenService { + token: string; + decodedToken: any; + refreshToken: string; + setTokens(accessToken: string, refreshToken: string): Promise; + setToken(token: string): Promise; + getToken(): Promise; + setRefreshToken(refreshToken: string): Promise; + getRefreshToken(): Promise; + setTwoFactorToken(token: string, email: string): Promise; + getTwoFactorToken(email: string): Promise; + clearTwoFactorToken(email: string): Promise; + clearToken(): Promise; + decodeToken(): any; + getTokenExpirationDate(): Date; + tokenSecondsRemaining(offsetSeconds?: number): number; + tokenNeedsRefresh(minutes?: number): boolean; + getUserId(): string; + getEmail(): string; + getName(): string; + getPremium(): boolean; + getIssuer(): string; +} diff --git a/src/services/api.service.ts b/src/services/api.service.ts new file mode 100644 index 0000000000..5e41da5236 --- /dev/null +++ b/src/services/api.service.ts @@ -0,0 +1,447 @@ +import { ConstantsService } from './constants.service'; + +import { ApiService as ApiServiceInterface } from '../abstractions/api.service'; +import { PlatformUtilsService } from '../abstractions/platformUtils.service'; +import { TokenService } from '../abstractions/token.service'; + +import { EnvironmentUrls } from '../models/domain/environmentUrls'; + +import { CipherRequest } from '../models/request/cipherRequest'; +import { FolderRequest } from '../models/request/folderRequest'; +import { PasswordHintRequest } from '../models/request/passwordHintRequest'; +import { RegisterRequest } from '../models/request/registerRequest'; +import { TokenRequest } from '../models/request/tokenRequest'; +import { TwoFactorEmailRequest } from '../models/request/twoFactorEmailRequest'; + +import { CipherResponse } from '../models/response/cipherResponse'; +import { ErrorResponse } from '../models/response/errorResponse'; +import { FolderResponse } from '../models/response/folderResponse'; +import { IdentityTokenResponse } from '../models/response/identityTokenResponse'; +import { SyncResponse } from '../models/response/syncResponse'; + +export class ApiService implements ApiServiceInterface { + urlsSet: boolean = false; + baseUrl: string; + identityBaseUrl: string; + deviceType: string; + logoutCallback: Function; + + constructor(private tokenService: TokenService, platformUtilsService: PlatformUtilsService, + logoutCallback: Function) { + this.logoutCallback = logoutCallback; + this.deviceType = platformUtilsService.getDevice().toString(); + } + + setUrls(urls: EnvironmentUrls) { + this.urlsSet = true; + + if (urls.base != null) { + this.baseUrl = urls.base + '/api'; + this.identityBaseUrl = urls.base + '/identity'; + return; + } + + if (urls.api != null && urls.identity != null) { + this.baseUrl = urls.api; + this.identityBaseUrl = urls.identity; + return; + } + + /* tslint:disable */ + // Desktop + //this.baseUrl = 'http://localhost:4000'; + //this.identityBaseUrl = 'http://localhost:33656'; + + // Desktop HTTPS + //this.baseUrl = 'https://localhost:44377'; + //this.identityBaseUrl = 'https://localhost:44392'; + + // Desktop external + //this.baseUrl = 'http://192.168.1.3:4000'; + //this.identityBaseUrl = 'http://192.168.1.3:33656'; + + // Preview + //this.baseUrl = 'https://preview-api.bitwarden.com'; + //this.identityBaseUrl = 'https://preview-identity.bitwarden.com'; + + // Production + this.baseUrl = 'https://api.bitwarden.com'; + this.identityBaseUrl = 'https://identity.bitwarden.com'; + /* tslint:enable */ + } + + // Auth APIs + + async postIdentityToken(request: TokenRequest): Promise { + const response = await fetch(new Request(this.identityBaseUrl + '/connect/token', { + body: this.qsStringify(request.toIdentityToken()), + cache: 'no-cache', + headers: new Headers({ + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8', + 'Accept': 'application/json', + 'Device-Type': this.deviceType, + }), + method: 'POST', + })); + + let responseJson: any = null; + const typeHeader = response.headers.get('content-type'); + if (typeHeader != null && typeHeader.indexOf('application/json') > -1) { + responseJson = await response.json(); + } + + if (responseJson != null) { + if (response.status === 200) { + return new IdentityTokenResponse(responseJson); + } else if (response.status === 400 && responseJson.TwoFactorProviders2 && + Object.keys(responseJson.TwoFactorProviders2).length) { + await this.tokenService.clearTwoFactorToken(request.email); + return responseJson.TwoFactorProviders2; + } + } + + return Promise.reject(new ErrorResponse(responseJson, response.status, true)); + } + + async refreshIdentityToken(): Promise { + try { + await this.doRefreshToken(); + } catch (e) { + return Promise.reject(null); + } + } + + // Two Factor APIs + + async postTwoFactorEmail(request: TwoFactorEmailRequest): Promise { + const response = await fetch(new Request(this.baseUrl + '/two-factor/send-email-login', { + body: JSON.stringify(request), + cache: 'no-cache', + headers: new Headers({ + 'Content-Type': 'application/json; charset=utf-8', + 'Device-Type': this.deviceType, + }), + method: 'POST', + })); + + if (response.status !== 200) { + const error = await this.handleError(response, false); + return Promise.reject(error); + } + } + + // Account APIs + + async getAccountRevisionDate(): Promise { + const authHeader = await this.handleTokenState(); + const response = await fetch(new Request(this.baseUrl + '/accounts/revision-date', { + cache: 'no-cache', + headers: new Headers({ + 'Accept': 'application/json', + 'Authorization': authHeader, + 'Device-Type': this.deviceType, + }), + })); + + if (response.status === 200) { + return (await response.json() as number); + } else { + const error = await this.handleError(response, false); + return Promise.reject(error); + } + } + + async postPasswordHint(request: PasswordHintRequest): Promise { + const response = await fetch(new Request(this.baseUrl + '/accounts/password-hint', { + body: JSON.stringify(request), + cache: 'no-cache', + headers: new Headers({ + 'Content-Type': 'application/json; charset=utf-8', + 'Device-Type': this.deviceType, + }), + method: 'POST', + })); + + if (response.status !== 200) { + const error = await this.handleError(response, false); + return Promise.reject(error); + } + } + + async postRegister(request: RegisterRequest): Promise { + const response = await fetch(new Request(this.baseUrl + '/accounts/register', { + body: JSON.stringify(request), + cache: 'no-cache', + headers: new Headers({ + 'Content-Type': 'application/json; charset=utf-8', + 'Device-Type': this.deviceType, + }), + method: 'POST', + })); + + if (response.status !== 200) { + const error = await this.handleError(response, false); + return Promise.reject(error); + } + } + + // Folder APIs + + async postFolder(request: FolderRequest): Promise { + const authHeader = await this.handleTokenState(); + const response = await fetch(new Request(this.baseUrl + '/folders', { + body: JSON.stringify(request), + cache: 'no-cache', + headers: new Headers({ + 'Accept': 'application/json', + 'Authorization': authHeader, + 'Content-Type': 'application/json; charset=utf-8', + 'Device-Type': this.deviceType, + }), + method: 'POST', + })); + + if (response.status === 200) { + const responseJson = await response.json(); + return new FolderResponse(responseJson); + } else { + const error = await this.handleError(response, false); + return Promise.reject(error); + } + } + + async putFolder(id: string, request: FolderRequest): Promise { + const authHeader = await this.handleTokenState(); + const response = await fetch(new Request(this.baseUrl + '/folders/' + id, { + body: JSON.stringify(request), + cache: 'no-cache', + headers: new Headers({ + 'Accept': 'application/json', + 'Authorization': authHeader, + 'Content-Type': 'application/json; charset=utf-8', + 'Device-Type': this.deviceType, + }), + method: 'PUT', + })); + + if (response.status === 200) { + const responseJson = await response.json(); + return new FolderResponse(responseJson); + } else { + const error = await this.handleError(response, false); + return Promise.reject(error); + } + } + + async deleteFolder(id: string): Promise { + const authHeader = await this.handleTokenState(); + const response = await fetch(new Request(this.baseUrl + '/folders/' + id, { + cache: 'no-cache', + headers: new Headers({ + 'Authorization': authHeader, + 'Device-Type': this.deviceType, + }), + method: 'DELETE', + })); + + if (response.status !== 200) { + const error = await this.handleError(response, false); + return Promise.reject(error); + } + } + + // Cipher APIs + + async postCipher(request: CipherRequest): Promise { + const authHeader = await this.handleTokenState(); + const response = await fetch(new Request(this.baseUrl + '/ciphers', { + body: JSON.stringify(request), + cache: 'no-cache', + headers: new Headers({ + 'Accept': 'application/json', + 'Authorization': authHeader, + 'Content-Type': 'application/json; charset=utf-8', + 'Device-Type': this.deviceType, + }), + method: 'POST', + })); + + if (response.status === 200) { + const responseJson = await response.json(); + return new CipherResponse(responseJson); + } else { + const error = await this.handleError(response, false); + return Promise.reject(error); + } + } + + async putCipher(id: string, request: CipherRequest): Promise { + const authHeader = await this.handleTokenState(); + const response = await fetch(new Request(this.baseUrl + '/ciphers/' + id, { + body: JSON.stringify(request), + cache: 'no-cache', + headers: new Headers({ + 'Accept': 'application/json', + 'Authorization': authHeader, + 'Content-Type': 'application/json; charset=utf-8', + 'Device-Type': this.deviceType, + }), + method: 'PUT', + })); + + if (response.status === 200) { + const responseJson = await response.json(); + return new CipherResponse(responseJson); + } else { + const error = await this.handleError(response, false); + return Promise.reject(error); + } + } + + async deleteCipher(id: string): Promise { + const authHeader = await this.handleTokenState(); + const response = await fetch(new Request(this.baseUrl + '/ciphers/' + id, { + cache: 'no-cache', + headers: new Headers({ + 'Authorization': authHeader, + 'Device-Type': this.deviceType, + }), + method: 'DELETE', + })); + + if (response.status !== 200) { + const error = await this.handleError(response, false); + return Promise.reject(error); + } + } + + // Attachments APIs + + async postCipherAttachment(id: string, data: FormData): Promise { + const authHeader = await this.handleTokenState(); + const response = await fetch(new Request(this.baseUrl + '/ciphers/' + id + '/attachment', { + body: data, + cache: 'no-cache', + headers: new Headers({ + 'Accept': 'application/json', + 'Authorization': authHeader, + 'Device-Type': this.deviceType, + }), + method: 'POST', + })); + + if (response.status === 200) { + const responseJson = await response.json(); + return new CipherResponse(responseJson); + } else { + const error = await this.handleError(response, false); + return Promise.reject(error); + } + } + + async deleteCipherAttachment(id: string, attachmentId: string): Promise { + const authHeader = await this.handleTokenState(); + const response = await fetch(new Request(this.baseUrl + '/ciphers/' + id + '/attachment/' + attachmentId, { + cache: 'no-cache', + headers: new Headers({ + 'Authorization': authHeader, + 'Device-Type': this.deviceType, + }), + method: 'DELETE', + })); + + if (response.status !== 200) { + const error = await this.handleError(response, false); + return Promise.reject(error); + } + } + + // Sync APIs + + async getSync(): Promise { + const authHeader = await this.handleTokenState(); + const response = await fetch(new Request(this.baseUrl + '/sync', { + cache: 'no-cache', + headers: new Headers({ + 'Accept': 'application/json', + 'Authorization': authHeader, + 'Device-Type': this.deviceType, + }), + })); + + if (response.status === 200) { + const responseJson = await response.json(); + return new SyncResponse(responseJson); + } else { + const error = await this.handleError(response, false); + return Promise.reject(error); + } + } + + // Helpers + + private async handleError(response: Response, tokenError: boolean): Promise { + if ((tokenError && response.status === 400) || response.status === 401 || response.status === 403) { + this.logoutCallback(true); + return null; + } + + let responseJson: any = null; + const typeHeader = response.headers.get('content-type'); + if (typeHeader != null && typeHeader.indexOf('application/json') > -1) { + responseJson = await response.json(); + } + + return new ErrorResponse(responseJson, response.status, tokenError); + } + + private async handleTokenState(): Promise { + let accessToken: string; + if (this.tokenService.tokenNeedsRefresh()) { + const tokenResponse = await this.doRefreshToken(); + accessToken = tokenResponse.accessToken; + } else { + accessToken = await this.tokenService.getToken(); + } + + return 'Bearer ' + accessToken; + } + + private async doRefreshToken(): Promise { + const refreshToken = await this.tokenService.getRefreshToken(); + if (refreshToken == null || refreshToken === '') { + throw new Error(); + } + + const response = await fetch(new Request(this.identityBaseUrl + '/connect/token', { + body: this.qsStringify({ + grant_type: 'refresh_token', + client_id: 'browser', + refresh_token: refreshToken, + }), + cache: 'no-cache', + headers: new Headers({ + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8', + 'Accept': 'application/json', + 'Device-Type': this.deviceType, + }), + method: 'POST', + })); + + if (response.status === 200) { + const responseJson = await response.json(); + const tokenResponse = new IdentityTokenResponse(responseJson); + await this.tokenService.setTokens(tokenResponse.accessToken, tokenResponse.refreshToken); + return tokenResponse; + } else { + const error = await this.handleError(response, true); + return Promise.reject(error); + } + } + + private qsStringify(params: any): string { + return Object.keys(params).map((key) => { + return encodeURIComponent(key) + '=' + encodeURIComponent(params[key]); + }).join('&'); + } +} diff --git a/src/services/appId.service.ts b/src/services/appId.service.ts new file mode 100644 index 0000000000..430a375571 --- /dev/null +++ b/src/services/appId.service.ts @@ -0,0 +1,28 @@ +import { UtilsService } from './utils.service'; + +import { AppIdService as AppIdServiceInterface } from '../abstractions/appId.service'; +import { StorageService } from '../abstractions/storage.service'; + +export class AppIdService implements AppIdServiceInterface { + constructor(private storageService: StorageService) { + } + + getAppId(): Promise { + return this.makeAndGetAppId('appId'); + } + + getAnonymousAppId(): Promise { + return this.makeAndGetAppId('anonymousAppId'); + } + + private async makeAndGetAppId(key: string) { + const existingId = await this.storageService.get(key); + if (existingId != null) { + return existingId; + } + + const guid = UtilsService.newGuid(); + await this.storageService.save(key, guid); + return guid; + } +} diff --git a/src/services/index.ts b/src/services/index.ts index 87ec7493d2..dd4122a826 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -1,3 +1,6 @@ +export { ApiService } from './api.service'; +export { AppIdService } from './appId.service'; export { ConstantsService } from './constants.service'; export { CryptoService } from './crypto.service'; +export { TokenService } from './token.service'; export { UtilsService } from './utils.service'; diff --git a/src/services/token.service.ts b/src/services/token.service.ts new file mode 100644 index 0000000000..552767e5e0 --- /dev/null +++ b/src/services/token.service.ts @@ -0,0 +1,176 @@ +import { ConstantsService } from './constants.service'; +import { UtilsService } from './utils.service'; + +import { StorageService } from '../abstractions/storage.service'; +import { TokenService as TokenServiceInterface } from '../abstractions/token.service'; + +const Keys = { + accessToken: 'accessToken', + refreshToken: 'refreshToken', + twoFactorTokenPrefix: 'twoFactorToken_', +}; + +export class TokenService implements TokenServiceInterface { + token: string; + decodedToken: any; + refreshToken: string; + + constructor(private storageService: StorageService) { + } + + setTokens(accessToken: string, refreshToken: string): Promise { + return Promise.all([ + this.setToken(accessToken), + this.setRefreshToken(refreshToken), + ]); + } + + setToken(token: string): Promise { + this.token = token; + this.decodedToken = null; + return this.storageService.save(Keys.accessToken, token); + } + + async getToken(): Promise { + if (this.token != null) { + return this.token; + } + + this.token = await this.storageService.get(Keys.accessToken); + return this.token; + } + + setRefreshToken(refreshToken: string): Promise { + this.refreshToken = refreshToken; + return this.storageService.save(Keys.refreshToken, refreshToken); + } + + async getRefreshToken(): Promise { + if (this.refreshToken != null) { + return this.refreshToken; + } + + this.refreshToken = await this.storageService.get(Keys.refreshToken); + return this.refreshToken; + } + + setTwoFactorToken(token: string, email: string): Promise { + return this.storageService.save(Keys.twoFactorTokenPrefix + email, token); + } + + getTwoFactorToken(email: string): Promise { + return this.storageService.get(Keys.twoFactorTokenPrefix + email); + } + + clearTwoFactorToken(email: string): Promise { + return this.storageService.remove(Keys.twoFactorTokenPrefix + email); + } + + clearToken(): Promise { + this.token = null; + this.decodedToken = null; + this.refreshToken = null; + + return Promise.all([ + this.storageService.remove(Keys.accessToken), + this.storageService.remove(Keys.refreshToken), + ]); + } + + // jwthelper methods + // ref https://github.com/auth0/angular-jwt/blob/master/src/angularJwt/services/jwt.js + + decodeToken(): any { + if (this.decodedToken) { + return this.decodedToken; + } + + if (this.token == null) { + throw new Error('Token not found.'); + } + + const parts = this.token.split('.'); + if (parts.length !== 3) { + throw new Error('JWT must have 3 parts'); + } + + const decoded = UtilsService.urlBase64Decode(parts[1]); + if (decoded == null) { + throw new Error('Cannot decode the token'); + } + + this.decodedToken = JSON.parse(decoded); + return this.decodedToken; + } + + getTokenExpirationDate(): Date { + const decoded = this.decodeToken(); + if (typeof decoded.exp === 'undefined') { + return null; + } + + const d = new Date(0); // The 0 here is the key, which sets the date to the epoch + d.setUTCSeconds(decoded.exp); + return d; + } + + tokenSecondsRemaining(offsetSeconds: number = 0): number { + const d = this.getTokenExpirationDate(); + if (d == null) { + return 0; + } + + const msRemaining = d.valueOf() - (new Date().valueOf() + (offsetSeconds * 1000)); + return Math.round(msRemaining / 1000); + } + + tokenNeedsRefresh(minutes: number = 5): boolean { + const sRemaining = this.tokenSecondsRemaining(); + return sRemaining < (60 * minutes); + } + + getUserId(): string { + const decoded = this.decodeToken(); + if (typeof decoded.sub === 'undefined') { + throw new Error('No user id found'); + } + + return decoded.sub as string; + } + + getEmail(): string { + const decoded = this.decodeToken(); + if (typeof decoded.email === 'undefined') { + throw new Error('No email found'); + } + + return decoded.email as string; + } + + getName(): string { + const decoded = this.decodeToken(); + if (typeof decoded.name === 'undefined') { + throw new Error('No name found'); + } + + return decoded.name as string; + } + + getPremium(): boolean { + const decoded = this.decodeToken(); + if (typeof decoded.premium === 'undefined') { + return false; + } + + return decoded.premium as boolean; + } + + getIssuer(): string { + const decoded = this.decodeToken(); + if (typeof decoded.iss === 'undefined') { + throw new Error('No issuer found'); + } + + return decoded.iss as string; + } +}