diff --git a/client/angular.json b/client/angular.json index 97f90e7..47bbf25 100644 --- a/client/angular.json +++ b/client/angular.json @@ -51,7 +51,13 @@ "vendorChunk": true, "extractLicenses": false, "sourceMap": true, - "namedChunks": true + "namedChunks": true, + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.development.ts" + } + ] } }, "defaultConfiguration": "production" diff --git a/client/package-lock.json b/client/package-lock.json index 82e4db9..1e3a8b1 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -8,7 +8,6 @@ "name": "client", "version": "0.0.0", "dependencies": { - "@abacritt/angularx-social-login": "^2.1.0", "@angular/animations": "^16.2.0", "@angular/common": "^16.2.0", "@angular/compiler": "^16.2.0", @@ -17,6 +16,7 @@ "@angular/platform-browser": "^16.2.0", "@angular/platform-browser-dynamic": "^16.2.0", "@angular/router": "^16.2.0", + "jwt-decode": "^3.1.2", "rxjs": "~7.8.0", "tslib": "^2.3.0", "zone.js": "~0.13.0" @@ -25,6 +25,7 @@ "@angular-devkit/build-angular": "^16.2.2", "@angular/cli": "~16.2.2", "@angular/compiler-cli": "^16.2.0", + "@types/google.accounts": "^0.0.9", "@types/jasmine": "~4.3.0", "autoprefixer": "^10.4.15", "jasmine-core": "~4.6.0", @@ -38,18 +39,6 @@ "typescript": "~5.1.3" } }, - "node_modules/@abacritt/angularx-social-login": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@abacritt/angularx-social-login/-/angularx-social-login-2.1.0.tgz", - "integrity": "sha512-wjKiwLhA0j/exrb17unRveAkjxYTf1qRYKXZqJfD4jSzHkODKjVpOVDmt7Q+T2m8fLJBGc304hRBuUVVEtl8xg==", - "dependencies": { - "tslib": ">=2.5.0" - }, - "peerDependencies": { - "@angular/common": ">=16.0.0", - "@angular/core": ">=16.0.0" - } - }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -3360,6 +3349,12 @@ "@types/send": "*" } }, + "node_modules/@types/google.accounts": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/@types/google.accounts/-/google.accounts-0.0.9.tgz", + "integrity": "sha512-brOjhe+gTlZ241FMKcLjKp44CRSwdXNe8cWvHg23FBXEop1QRNhK9Gzs438dbCwCnZ0cCWDkQaaMBBYsZatZJg==", + "dev": true + }, "node_modules/@types/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.1.tgz", @@ -7639,6 +7634,11 @@ "node >= 0.2.0" ] }, + "node_modules/jwt-decode": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", + "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==" + }, "node_modules/karma": { "version": "6.4.2", "resolved": "https://registry.npmjs.org/karma/-/karma-6.4.2.tgz", diff --git a/client/package.json b/client/package.json index c7d952d..206dc81 100644 --- a/client/package.json +++ b/client/package.json @@ -10,7 +10,6 @@ }, "private": true, "dependencies": { - "@abacritt/angularx-social-login": "^2.1.0", "@angular/animations": "^16.2.0", "@angular/common": "^16.2.0", "@angular/compiler": "^16.2.0", @@ -19,6 +18,7 @@ "@angular/platform-browser": "^16.2.0", "@angular/platform-browser-dynamic": "^16.2.0", "@angular/router": "^16.2.0", + "jwt-decode": "^3.1.2", "rxjs": "~7.8.0", "tslib": "^2.3.0", "zone.js": "~0.13.0" @@ -27,6 +27,7 @@ "@angular-devkit/build-angular": "^16.2.2", "@angular/cli": "~16.2.2", "@angular/compiler-cli": "^16.2.0", + "@types/google.accounts": "^0.0.9", "@types/jasmine": "~4.3.0", "autoprefixer": "^10.4.15", "jasmine-core": "~4.6.0", @@ -39,4 +40,4 @@ "tailwindcss": "^3.3.3", "typescript": "~5.1.3" } -} +} \ No newline at end of file diff --git a/client/src/app/app-routing.module.ts b/client/src/app/app-routing.module.ts index b3c0b7e..4c9622a 100644 --- a/client/src/app/app-routing.module.ts +++ b/client/src/app/app-routing.module.ts @@ -1,13 +1,19 @@ import { NgModule, inject } from '@angular/core'; import { CanActivateFn, Router, RouterModule, Routes } from '@angular/router'; import { UserService } from './core/services/user.service'; -import { map } from 'rxjs'; +import { Subject, finalize, lastValueFrom, map, takeUntil } from 'rxjs'; const authGuard: CanActivateFn = () => { const userService: UserService = inject(UserService); const router: Router = inject(Router); + return userService.isAuthenticated.pipe( - map((isAuth) => isAuth || router.parseUrl('/login')) + map((isAuth) => { + if (isAuth) { + return true; + } + return router.parseUrl('/login'); + }) ); }; @@ -15,14 +21,16 @@ const loginGuard: CanActivateFn = () => { const userService: UserService = inject(UserService); const router: Router = inject(Router); - return userService.isAuthenticated.pipe( - map((isAuth) => { - if (!isAuth) { - return true; + return lastValueFrom(userService.getCurrentUser()) + .then(({ user }) => { + if (user) { + return router.parseUrl('/dashboard'); } - return router.parseUrl('/dashboard'); + return true; }) - ); + .catch(() => { + return true; + }); }; const routes: Routes = [ @@ -33,10 +41,11 @@ const routes: Routes = [ canActivate: [loginGuard], }, { - path: 'register', + path: 'callback', loadComponent: () => - import('./core/auth/auth.component').then((m) => m.AuthComponent), - canActivate: [loginGuard], + import('./core/callback/callback.component').then( + (m) => m.CallbackComponent + ), }, { path: '', @@ -59,9 +68,10 @@ const routes: Routes = [ ], }, { - path: 'log', - loadComponent: () => import('./features/changelog/changelog.component').then((m) => m.ChangelogComponent), - } + path: '**', + pathMatch: 'full', + redirectTo: 'dashboard', + }, ]; @NgModule({ diff --git a/client/src/app/app.component.ts b/client/src/app/app.component.ts index ddda137..9e0760b 100644 --- a/client/src/app/app.component.ts +++ b/client/src/app/app.component.ts @@ -1,7 +1,3 @@ -import { - GoogleLoginProvider, - SocialAuthService, -} from '@abacritt/angularx-social-login'; import { Component, OnInit } from '@angular/core'; @Component({ diff --git a/client/src/app/app.module.ts b/client/src/app/app.module.ts index b0f866a..4ddf2a0 100644 --- a/client/src/app/app.module.ts +++ b/client/src/app/app.module.ts @@ -2,11 +2,6 @@ import { NgModule } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import { AppRoutingModule } from './app-routing.module'; -import { - GoogleLoginProvider, - SocialAuthServiceConfig, - SocialLoginModule, -} from '@abacritt/angularx-social-login'; import { FooterComponent } from './core/layout/footer.component'; import { HeaderComponent } from './core/layout/header.component'; import { HttpClientModule } from '@angular/common/http'; @@ -19,30 +14,8 @@ import { AppComponent } from './app.component'; FooterComponent, HeaderComponent, AppRoutingModule, - SocialLoginModule, HttpClientModule, ], - providers: [ - { - provide: 'SocialAuthServiceConfig', - useValue: { - autoLogin: false, - providers: [ - { - id: GoogleLoginProvider.PROVIDER_ID, - provider: new GoogleLoginProvider( - '250904650832-atnankrca4pvegjofnp24hmefjke4doq.apps.googleusercontent.com', - { - oneTapEnabled: true, - scopes: 'profile email', - prompt: 'select_account', - } - ), - }, - ], - } as SocialAuthServiceConfig, - }, - ], bootstrap: [AppComponent], }) export class AppModule {} diff --git a/client/src/app/core/auth/auth.component.html b/client/src/app/core/auth/auth.component.html index bbfa28a..cf64f5b 100644 --- a/client/src/app/core/auth/auth.component.html +++ b/client/src/app/core/auth/auth.component.html @@ -1,84 +1,59 @@ -
+
Your Company

- Sign in to your account + Sign in with your @Forth account

-
--> + + +
-
-
-
- -
- -
-
- -
-
- - -
-
- -
-
- -
- -
-
-
diff --git a/client/src/app/core/auth/auth.component.ts b/client/src/app/core/auth/auth.component.ts index 771392b..51af6e9 100644 --- a/client/src/app/core/auth/auth.component.ts +++ b/client/src/app/core/auth/auth.component.ts @@ -1,79 +1,26 @@ import { NgIf } from '@angular/common'; import { Component, OnDestroy, OnInit } from '@angular/core'; -import { FormControl, FormGroup, Validators } from '@angular/forms'; -import { ActivatedRoute, Router, RouterLink } from '@angular/router'; -import { Subject, takeUntil } from 'rxjs'; +import { Router, RouterLink } from '@angular/router'; +import { Subject } from 'rxjs'; import { Errors } from '../models/errors.model'; import { UserService } from '../services/user.service'; -import { - GoogleSigninButtonModule, - SocialAuthService, -} from '@abacritt/angularx-social-login'; -import { User } from '../models/user.model'; - -interface AuthForm { - email: FormControl; - password: FormControl; - username?: FormControl; -} +import jwtDecode from 'jwt-decode'; +import { HttpClient } from '@angular/common/http'; @Component({ selector: 'app-auth-page', templateUrl: './auth.component.html', - imports: [RouterLink, NgIf, GoogleSigninButtonModule], + imports: [RouterLink, NgIf], standalone: true, }) export class AuthComponent implements OnInit, OnDestroy { - authType = ''; title = ''; errors: Errors = { errors: {} }; - isSubmitting = false; - authForm: FormGroup; destroy$ = new Subject(); - - constructor( - private readonly route: ActivatedRoute, - private readonly router: Router, - private readonly userService: UserService, - private readonly authService: SocialAuthService - ) { - this.authForm = new FormGroup({ - email: new FormControl('', { - validators: [Validators.required, Validators.email], - nonNullable: true, - }), - password: new FormControl('', { - validators: [Validators.required, Validators.minLength(8)], - nonNullable: true, - }), - }); - } + constructor() {} ngOnInit(): void { - this.authType = this.route.snapshot.url.at(-1)!.path; - this.title = this.authType === 'login' ? 'Sign in' : 'Sign up'; - if (this.authType === 'resigter') { - this.authForm.addControl( - 'username', - new FormControl('', { - validators: [Validators.required], - nonNullable: true, - }) - ); - } - - // google login - this.authService.authState.subscribe((user) => { - this.userService.setAuth({ - username: user.name, - email: user.email, - token: user.idToken, - image: user.photoUrl, - bio: '', - } satisfies User); - - void this.router.navigate(['/dashboard']); - }); + this.title = 'Sign in'; } ngOnDestroy(): void { @@ -81,34 +28,8 @@ export class AuthComponent implements OnInit, OnDestroy { this.destroy$.complete(); } - summitForm(): void { - this.isSubmitting = true; - this.errors = { errors: {} }; - - let observable; - if (this.authType === 'login') { - observable = this.userService.login( - this.authForm.value as { - email: string; - password: string; - } - ); - } else { - observable = this.userService.register( - this.authForm.value as { - email: string; - password: string; - username: string; - } - ); - } - - observable.pipe(takeUntil(this.destroy$)).subscribe({ - next: () => void this.router.navigate(['/login']), - error: (err: Errors) => { - this.errors = err; - this.isSubmitting = false; - }, - }); + loginWithGoogle(): void { + // redirect to google login in server + window.location.href = 'http://localhost:8080/auth/google'; } } diff --git a/client/src/app/core/callback/callback.component.html b/client/src/app/core/callback/callback.component.html new file mode 100644 index 0000000..9f99301 --- /dev/null +++ b/client/src/app/core/callback/callback.component.html @@ -0,0 +1 @@ + diff --git a/client/src/app/core/callback/callback.component.ts b/client/src/app/core/callback/callback.component.ts new file mode 100644 index 0000000..e02865b --- /dev/null +++ b/client/src/app/core/callback/callback.component.ts @@ -0,0 +1,37 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ActivatedRoute, Params, Router } from '@angular/router'; +import { UserService } from '../services/user.service'; + +@Component({ + selector: 'app-callback', + standalone: true, + templateUrl: './callback.component.html', +}) +export class CallbackComponent implements OnInit { + constructor( + private route: ActivatedRoute, + private router: Router, + private userService: UserService + ) {} + + ngOnInit(): void { + this.route.queryParams.subscribe((params) => { + console.log(params); + + if (params['email'] && params['name'] && params['picture']) { + this.userService.setAuth({ + email: params['email'], + username: params['name'], + image: params['picture'], + }); + } + + if (params['redirect_to']) { + this.router.navigate([params['redirect_to']]); + } else { + this.router.navigate(['/']); + } + }); + } +} diff --git a/client/src/app/core/interceptors/error.interceptor.ts b/client/src/app/core/interceptors/error.interceptor.ts new file mode 100644 index 0000000..51aae63 --- /dev/null +++ b/client/src/app/core/interceptors/error.interceptor.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@angular/core'; +import { + HttpRequest, + HttpHandler, + HttpEvent, + HttpInterceptor, + HttpErrorResponse, +} from '@angular/common/http'; +import { Observable, catchError, retry, throwError } from 'rxjs'; + +@Injectable() +export class ErrorInterceptor implements HttpInterceptor { + constructor() {} + + intercept( + request: HttpRequest, + next: HttpHandler + ): Observable> { + return next.handle(request).pipe( + catchError((error) => { + if (error instanceof HttpErrorResponse) { + if (error.status === 401) { + return next.handle(request); + } + } + + return throwError(() => error); + }) + ); + } +} diff --git a/client/src/app/core/models/user.model.ts b/client/src/app/core/models/user.model.ts index a5d57ef..c825f21 100644 --- a/client/src/app/core/models/user.model.ts +++ b/client/src/app/core/models/user.model.ts @@ -1,7 +1,5 @@ export interface User { email: string; - token: string; username: string; - bio: string; image: string; } diff --git a/client/src/app/core/services/user.service.ts b/client/src/app/core/services/user.service.ts index 96c9919..58a5643 100644 --- a/client/src/app/core/services/user.service.ts +++ b/client/src/app/core/services/user.service.ts @@ -3,13 +3,14 @@ import { HttpClient } from '@angular/common/http'; import { BehaviorSubject, Observable, + concat, distinctUntilChanged, map, tap, } from 'rxjs'; import { User } from '../models/user.model'; import { Router } from '@angular/router'; -import { JwtService } from './jwt.service'; +import { environment } from 'src/environments/environment'; @Injectable({ providedIn: 'root' }) export class UserService { @@ -18,64 +19,45 @@ export class UserService { .asObservable() .pipe(distinctUntilChanged()); - public isAuthenticated = this.currentUser.pipe( - map((user) => { - return user !== null; - }) - ); + public isAuthenticated = this.currentUser.pipe(map((user) => !!user)); constructor( private readonly http: HttpClient, - private readonly router: Router, - private readonly jwtService: JwtService + private readonly router: Router ) {} - login(credentials: { - email: string; - password: string; - }): Observable<{ user: User }> { - return this.http - .post<{ user: User }>('/api/users/login', credentials) - .pipe(tap(({ user }) => this.setAuth(user))); - } - - register(credentials: { - username: string; - email: string; - password: string; - }): Observable<{ user: User }> { - return this.http - .post<{ user: User }>('/api/users', { user: credentials }) - .pipe(tap(({ user }) => this.setAuth(user))); - } - logout(): void { this.purgeAuth(); - void this.router.navigate(['/login']); + // post to api /revoke with cookie + this.http + .get(environment.api + '/auth/revoke', { + withCredentials: true, + }) + .subscribe({ + complete: () => this.router.navigateByUrl('/login'), + }); } getCurrentUser(): Observable<{ user: User }> { - return this.http.get<{ user: User }>('/api/user').pipe( - tap({ - next: ({ user }) => this.setAuth(user), - error: () => this.purgeAuth(), - }) - ); - } - - update(user: Partial): Observable<{ user: User }> { return this.http - .put<{ user: User }>('/api/user', { user }) - .pipe(tap(({ user }) => this.currnetUserSubject.next(user))); + .get<{ user: User }>(environment.api + '/auth/user', { + withCredentials: true, + }) + .pipe( + tap({ + next: ({ user }) => { + this.setAuth(user); + }, + error: () => this.purgeAuth(), + }) + ); } setAuth(user: User): void { - this.jwtService.saveToken(user.token); void this.currnetUserSubject.next(user); } purgeAuth(): void { - this.jwtService.destroyToken(); - this.currnetUserSubject.next(null); + void this.currnetUserSubject.next(null); } } diff --git a/client/src/app/features/dashboard/dashboard.component.html b/client/src/app/features/dashboard/dashboard.component.html index d96f3cc..199919e 100644 --- a/client/src/app/features/dashboard/dashboard.component.html +++ b/client/src/app/features/dashboard/dashboard.component.html @@ -1,4 +1,9 @@

dashboard works!

+
+ +

Hi {{ userInfo.username }}

+

Your email is {{ userInfo.email }}

+
diff --git a/client/src/app/features/dashboard/dashboard.component.ts b/client/src/app/features/dashboard/dashboard.component.ts index 2892038..dee5df5 100644 --- a/client/src/app/features/dashboard/dashboard.component.ts +++ b/client/src/app/features/dashboard/dashboard.component.ts @@ -1,21 +1,29 @@ -import { Component } from '@angular/core'; -import { CommonModule } from '@angular/common'; +import { Component, OnInit } from '@angular/core'; import { UserService } from 'src/app/core/services/user.service'; -import { SocialAuthService } from '@abacritt/angularx-social-login'; -import { Router } from '@angular/router'; +import { HttpClient } from '@angular/common/http'; +import { User } from 'src/app/core/models/user.model'; +import { NgIf } from '@angular/common'; @Component({ selector: 'app-dashboard', standalone: true, + imports: [NgIf], templateUrl: './dashboard.component.html', styleUrls: ['./dashboard.component.css'], }) -export class DashboardComponent { +export class DashboardComponent implements OnInit { + userInfo: User | null = null; + constructor(private _userService: UserService) {} + ngOnInit(): void { + this._userService.currentUser.subscribe((user) => { + this.userInfo = user; + }); + } + logout() { console.log('logout'); this._userService.logout(); - // this._authService.signOut(); } } diff --git a/client/src/assets/google-color.svg b/client/src/assets/google-color.svg new file mode 100644 index 0000000..d5f4dbe --- /dev/null +++ b/client/src/assets/google-color.svg @@ -0,0 +1,28 @@ + + + + + Google-color + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/client/src/assets/google-color.svg:Zone.Identifier b/client/src/assets/google-color.svg:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/client/src/assets/logo.png b/client/src/assets/logo.png new file mode 100644 index 0000000..4bd4910 Binary files /dev/null and b/client/src/assets/logo.png differ diff --git a/client/src/environments/environment.development.ts b/client/src/environments/environment.development.ts new file mode 100644 index 0000000..dd9d9ef --- /dev/null +++ b/client/src/environments/environment.development.ts @@ -0,0 +1,4 @@ +export const environment = { + production: false, + api: 'http://localhost:8080', +}; diff --git a/client/src/environments/environment.ts b/client/src/environments/environment.ts new file mode 100644 index 0000000..1a61762 --- /dev/null +++ b/client/src/environments/environment.ts @@ -0,0 +1,4 @@ +export const environment = { + production: true, + api: '', +}; diff --git a/client/src/index.html b/client/src/index.html index 84f6fcd..76fb30c 100644 --- a/client/src/index.html +++ b/client/src/index.html @@ -6,6 +6,7 @@ + diff --git a/client/tsconfig.app.json b/client/tsconfig.app.json index 374cc9d..e4a30a8 100644 --- a/client/tsconfig.app.json +++ b/client/tsconfig.app.json @@ -3,7 +3,9 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./out-tsc/app", - "types": [] + "types": [ + "google.accounts" + ] }, "files": [ "src/main.ts" @@ -11,4 +13,4 @@ "include": [ "src/**/*.d.ts" ] -} +} \ No newline at end of file diff --git a/client/tsconfig.json b/client/tsconfig.json index ed966d4..703e383 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -30,4 +30,4 @@ "strictInputAccessModifiers": true, "strictTemplates": true } -} +} \ No newline at end of file diff --git a/client/tsconfig.spec.json b/client/tsconfig.spec.json index be7e9da..7885b0c 100644 --- a/client/tsconfig.spec.json +++ b/client/tsconfig.spec.json @@ -4,11 +4,12 @@ "compilerOptions": { "outDir": "./out-tsc/spec", "types": [ - "jasmine" + "jasmine", + "google.accounts" ] }, "include": [ "src/**/*.spec.ts", "src/**/*.d.ts" ] -} +} \ No newline at end of file diff --git a/server/.gitignore b/server/.gitignore index 5227556..f56cf14 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -1 +1,2 @@ -/cofffeemachineConfig \ No newline at end of file +/cofffeemachineConfig +client_secret.json diff --git a/server/go.mod b/server/go.mod index 1e22543..3cfa9ef 100644 --- a/server/go.mod +++ b/server/go.mod @@ -2,6 +2,15 @@ module recipe-manager go 1.21.1 -require github.com/go-chi/chi/v5 v5.0.10 +require ( + github.com/go-chi/chi/v5 v5.0.10 + github.com/go-chi/cors v1.2.1 + golang.org/x/oauth2 v0.12.0 +) -require github.com/go-chi/cors v1.2.1 // indirect +require ( + github.com/golang/protobuf v1.5.3 // indirect + golang.org/x/net v0.15.0 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/protobuf v1.31.0 // indirect +) diff --git a/server/go.sum b/server/go.sum index abb0845..69e4cc3 100644 --- a/server/go.sum +++ b/server/go.sum @@ -2,3 +2,27 @@ github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk= github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/oauth2 v0.12.0 h1:smVPGxink+n1ZI5pkQa8y6fZT0RW0MgCO5bFpepy4B4= +golang.org/x/oauth2 v0.12.0/go.mod h1:A74bZ3aGXgCY0qaIC9Ahg6Lglin4AMAco8cIv9baba4= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= diff --git a/server/main.go b/server/main.go index e5925f9..ec8a83d 100644 --- a/server/main.go +++ b/server/main.go @@ -10,7 +10,7 @@ import ( ) func main() { - s := NewServer(3000) + s := NewServer(8080) serverCtx, serverStopCtx := context.WithCancel(context.Background()) sig := make(chan os.Signal, 1) diff --git a/server/routers/auth.go b/server/routers/auth.go new file mode 100644 index 0000000..eff1d97 --- /dev/null +++ b/server/routers/auth.go @@ -0,0 +1,198 @@ +package routers + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "net/http" + "net/url" + "os" + + "github.com/go-chi/chi/v5" + "golang.org/x/oauth2" +) + +type AuthRouter struct { + gConfig *oauth2.Config + nonce map[string]map[string]string +} + +func NewAuthRouter() *AuthRouter { + + file, err := os.Open("client_secret.json") + if err != nil { + panic(err) + } + defer file.Close() + + var clientSecret map[string]interface{} + json.NewDecoder(file).Decode(&clientSecret) + + return &AuthRouter{ + gConfig: &oauth2.Config{ + ClientID: clientSecret["web"].(map[string]interface{})["client_id"].(string), + ClientSecret: clientSecret["web"].(map[string]interface{})["client_secret"].(string), + RedirectURL: "http://localhost:8080/auth/google/callback", + Scopes: []string{"https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile"}, + Endpoint: oauth2.Endpoint{ + AuthURL: clientSecret["web"].(map[string]interface{})["auth_uri"].(string), + TokenURL: clientSecret["web"].(map[string]interface{})["token_uri"].(string), + }, + }, + nonce: make(map[string]map[string]string), + } +} + +func (ar *AuthRouter) Route(r chi.Router) { + r.Route("/auth", func(r chi.Router) { + r.Get("/google", func(w http.ResponseWriter, r *http.Request) { + + // generate state and nonce + bytes := make([]byte, 32) + rand.Read(bytes) + state := base64.URLEncoding.EncodeToString(bytes) + + stateMap := map[string]string{} + + if r.URL.Query().Get("redirect_to") != "" { + stateMap["redirect_to"] = r.URL.Query().Get("redirect_to") + } + + ar.nonce[state] = stateMap + + url := ar.gConfig.AuthCodeURL(state, oauth2.SetAuthURLParam("hd", "forth.co.th"), oauth2.SetAuthURLParam("include_granted_scopes", "true"), oauth2.AccessTypeOffline) + http.Redirect(w, r, url, http.StatusTemporaryRedirect) + }) + + r.Get("/google/callback", func(w http.ResponseWriter, r *http.Request) { + // check state + + var redirect_to string + if r.URL.Query().Get("state") == "" { + http.Error(w, "State not found", http.StatusBadRequest) + return + } else { + val, ok := ar.nonce[r.URL.Query().Get("state")] + + if !ok { + http.Error(w, "Invalid state", http.StatusBadRequest) + return + } + + redirect_to = val["redirect_to"] + + // remove state from nonce + delete(ar.nonce, r.URL.Query().Get("state")) + } + + // exchange code for token + token, err := ar.gConfig.Exchange(r.Context(), r.FormValue("code")) + if err != nil { + http.Error(w, "Error exchanging code for token", http.StatusBadRequest) + return + } + + // get user info + client := ar.gConfig.Client(r.Context(), token) + resp, err := client.Get("https://www.googleapis.com/oauth2/v3/userinfo") + if err != nil { + http.Error(w, "Error getting user info", http.StatusBadRequest) + return + } + defer resp.Body.Close() + + var userInfo map[string]interface{} + json.NewDecoder(resp.Body).Decode(&userInfo) + + value := url.Values{} + + value.Add("email", userInfo["email"].(string)) + value.Add("name", userInfo["name"].(string)) + value.Add("picture", userInfo["picture"].(string)) + if redirect_to != "" { + value.Add("redirect_to", redirect_to) + } + + // redirect to frontend with token and refresh token + w.Header().Add("set-cookie", "access_token="+token.AccessToken+"; Path=/; HttpOnly; SameSite=None; Secure") + w.Header().Add("set-cookie", "refresh_token="+token.RefreshToken+"; Path=/; HttpOnly; SameSite=None; Secure") + http.Redirect(w, r, "http://localhost:4200/callback?"+value.Encode(), http.StatusTemporaryRedirect) + }) + + r.Get("/refresh", func(w http.ResponseWriter, r *http.Request) { + // get refresh token from query string + refreshToken := r.URL.Query().Get("refresh_token") + redirectTo := r.URL.Query().Get("redirect_to") + if refreshToken == "" { + http.Error(w, "Refresh token not found", http.StatusBadRequest) + return + } + + // exchange refresh token for new token + token, err := ar.gConfig.TokenSource(r.Context(), &oauth2.Token{RefreshToken: refreshToken}).Token() + if err != nil { + http.Error(w, "Error exchanging refresh token for token", http.StatusBadRequest) + return + } + + // redirect to frontend with token and refresh token + http.Redirect(w, r, "http://localhost:4200/callback?token="+token.AccessToken+"&redirect_to="+redirectTo, http.StatusTemporaryRedirect) + }) + + r.Get("/revoke", func(w http.ResponseWriter, r *http.Request) { + // get access token and refresh token from cookie + if cookie, err := r.Cookie("access_token"); err == nil { + // request to revoke token at oauth2.googleapis.com/revoke + _, err := http.PostForm("https://oauth2.googleapis.com/revoke", url.Values{"token": {cookie.Value}}) + if err != nil { + http.Error(w, "Error revoking token", http.StatusBadRequest) + return + } + } + + // remove cookie with expire from frontend and response no content + w.Header().Add("set-cookie", "access_token=; Path=/; HttpOnly; SameSite=None; Secure; Max-Age=0") + w.Header().Add("set-cookie", "refresh_token=; Path=/; HttpOnly; SameSite=None; Secure; Max-Age=0") + w.WriteHeader(http.StatusNoContent) + }) + + r.Get("/user", func(w http.ResponseWriter, r *http.Request) { + + token := &oauth2.Token{} + if cookie, err := r.Cookie("access_token"); err == nil { + token.AccessToken = cookie.Value + } + + // if have refresh token, set refresh token to token + if cookie, err := r.Cookie("refresh_token"); err == nil { + token.RefreshToken = cookie.Value + } + + // get user info + client := ar.gConfig.Client(r.Context(), token) + resp, err := client.Get("https://www.googleapis.com/oauth2/v3/userinfo") + if err != nil { + http.Error(w, "Error getting user info", http.StatusBadRequest) + return + } + defer resp.Body.Close() + + var userInfo map[string]interface{} + if err = json.NewDecoder(resp.Body).Decode(&userInfo); err != nil { + http.Error(w, "Error getting user info", http.StatusBadRequest) + return + } + + // return user info + w.Header().Add("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "user": map[string]string{ + "email": userInfo["email"].(string), + "username": userInfo["name"].(string), + "image": userInfo["picture"].(string), + }, + }) + }) + + }) +} diff --git a/server/routers/recipe.go b/server/routers/recipe.go index e1d97c1..e9dc32a 100644 --- a/server/routers/recipe.go +++ b/server/routers/recipe.go @@ -18,7 +18,7 @@ func NewRecipeRouter(data *data.Data) *RecipeRouter { } } -func (rr *RecipeRouter) Route(r *chi.Mux) { +func (rr *RecipeRouter) Route(r chi.Router) { r.Route("/recipes", func(r chi.Router) { r.Get("/", func(w http.ResponseWriter, r *http.Request) { w.Header().Add("Content-Type", "application/json") diff --git a/server/server.go b/server/server.go index c697b79..e8834fa 100644 --- a/server/server.go +++ b/server/server.go @@ -6,6 +6,7 @@ import ( "fmt" "log" "net/http" + "net/url" "os" "os/exec" "recipe-manager/data" @@ -36,54 +37,100 @@ func (s *Server) Run() error { func createHandler() http.Handler { r := chi.NewRouter() + r.Use(cors.Handler(cors.Options{ - AllowedOrigins: []string{"https://*", "http://*"}, + AllowedOrigins: []string{"http://localhost:4200"}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, AllowCredentials: true, - ExposedHeaders: []string{"Content-Type"}, - AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"}, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + MaxAge: 300, // Maximum value not ignored by any of major browsers + // Debug: true, })) - r.Post("/merge", func(w http.ResponseWriter, r *http.Request) { - var targetMap map[string]interface{} - err := json.NewDecoder(r.Body).Decode(&targetMap) - if err != nil { - w.WriteHeader(http.StatusBadRequest) - log.Fatalln("Merge request failed: ", err) - return - } - log.Println(targetMap) - master_version := targetMap["master"].(string) - dev_version := targetMap["dev"].(string) - // find target file in the cofffeemachineConfig - if _, err := os.Stat("coffeemachineConfig/coffeethai02_" + master_version + ".json"); err != nil { - w.WriteHeader(http.StatusBadRequest) - log.Fatalln("Merge request failed. Master file not found: ", err) - return - } - if _, err := os.Stat("coffeemachineConfig/coffeethai02_" + dev_version + ".json"); err != nil { - w.WriteHeader(http.StatusBadRequest) - log.Fatalln("Merge request failed. Dev file not found: ", err) - return - } + database := data.NewData() - repo_path := "coffeemachineConfig/coffeethai02_" - master_path := repo_path + master_version + ".json" - dev_path := repo_path + dev_version + ".json" - - // output path - output_path := "" - - // changelog path - changelog_path := "" - - // TODO: Call merge api if found - err = exec.Command("python", "merge", master_path, dev_path, output_path, changelog_path).Run() - if err != nil { - log.Fatalln("Merge request failed. Python merge failed: ", err) - } + // Auth Router + r.Group(func(r chi.Router) { + ar := routers.NewAuthRouter() + ar.Route(r) + }) + + // Protect Group + r.Group(func(r chi.Router) { + r.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + + cookie, err := r.Cookie("access_token") + + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + return + } + + // verify token by request to google api + res, err := http.Get("https://www.googleapis.com/oauth2/v3/tokeninfo?id_token=" + url.QueryEscape(cookie.Value)) + + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + return + } + + defer res.Body.Close() + + var tokenInfo map[string]interface{} + json.NewDecoder(res.Body).Decode(&tokenInfo) + + log.Println(tokenInfo) + + next.ServeHTTP(w, r) + }) + }) + + // Recipe Router + rr := routers.NewRecipeRouter(database) + rr.Route(r) + + r.Post("/merge", func(w http.ResponseWriter, r *http.Request) { + var targetMap map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&targetMap) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + log.Fatalln("Merge request failed: ", err) + return + } + log.Println(targetMap) + master_version := targetMap["master"].(string) + dev_version := targetMap["dev"].(string) + + // find target file in the cofffeemachineConfig + if _, err := os.Stat("coffeemachineConfig/coffeethai02_" + master_version + ".json"); err != nil { + w.WriteHeader(http.StatusBadRequest) + log.Fatalln("Merge request failed. Master file not found: ", err) + return + } + if _, err := os.Stat("coffeemachineConfig/coffeethai02_" + dev_version + ".json"); err != nil { + w.WriteHeader(http.StatusBadRequest) + log.Fatalln("Merge request failed. Dev file not found: ", err) + return + } + + repo_path := "coffeemachineConfig/coffeethai02_" + master_path := repo_path + master_version + ".json" + dev_path := repo_path + dev_version + ".json" + + // output path + output_path := "" + + // changelog path + changelog_path := "" + + // TODO: Call merge api if found + err = exec.Command("python", "merge", master_path, dev_path, output_path, changelog_path).Run() + if err != nil { + log.Fatalln("Merge request failed. Python merge failed: ", err) + } + }) }) - rr := routers.NewRecipeRouter(data.NewData()) - rr.Route(r) r.NotFound(func(w http.ResponseWriter, r *http.Request) { w.Header().Add("Content-Type", "application/json")