Skip to content

Commit

Permalink
✨ feat: authentication
Browse files Browse the repository at this point in the history
  • Loading branch information
perebaj committed Sep 29, 2023
1 parent ffe169e commit 407f832
Show file tree
Hide file tree
Showing 15 changed files with 502 additions and 40 deletions.
3 changes: 2 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,8 @@ dev/%:
## API IP
.PHONY: ip
ip:
@docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' contractus_contractus_1
$(eval IP := $(shell docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' contractus_contractus_1))
@echo "http://$(IP):8080"

## Display help for all targets
.PHONY: help
Expand Down
23 changes: 20 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,25 @@ Jamming around orders with API endpoints 🎸

## Environment Variables

**Required** environment variables

CONTRACTUS_POSTGRES_URL
CONTRACTUS_GOOGLE_CLIENT_ID
CONTRACTUS_GOOGLE_CLIENT_SECRET
CONTRACTUS_GOOGLE_REDIRECT_URL
CONTRACTUS_JWT_SECRET_KEY

## Get Started

To start the service locally, you can type `make dev/start` and after that you can use the docker container IP to play around the routes, `make ip`

Request example:

curl http://(make ip):8080
curl (make ip)

## Command line
All commands are synthesized in the Makefile, to start the development environment, just run:

All commands are synthesized in the Makefile `make help`, to start the development environment, just run:

make dev/start
make dev <- You will be able to run commands inside the container
Expand All @@ -41,9 +52,15 @@ The testcase variable could be used to run a specific test
`make image/publish`
`heroku container:release web -a contractus`

## Logs in production
## Logs

Production
`heroku logs --tail -a contractus`

Local:
`make dev/logs contractus`


## API documentation
[API Docs](api/docs/)

Expand Down
2 changes: 1 addition & 1 deletion api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func sendErr(w http.ResponseWriter, statusCode int, err error) {
}
}
if statusCode >= 500 {
slog.Error("Unable to process request", "error", err.Error(), "status_code", statusCode)
slog.Error("Unable to process request", "error", err, "status_code", statusCode)
}

send(w, statusCode, httpErr)
Expand Down
118 changes: 118 additions & 0 deletions api/authhandler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// Package api contains the authentication endpoints.
package api

import (
"encoding/json"
"errors"
"net/http"

"log/slog"

"github.com/go-chi/chi/v5"
"github.com/perebaj/contractus"
"golang.org/x/oauth2"
)

// Auth endpoints

// TODO(JOJO): randomize this
var randState = "random"

// RegisterAuthHandler register the auth endpoints.
func RegisterAuthHandler(r chi.Router, a Auth) {
const (
loginURL = "/"
callbackURL = "/callback"
tokenURL = "/token"
)
r.Method(http.MethodGet, loginURL, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
login(w, r, a)
}))
r.Method(http.MethodGet, callbackURL, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callback(w, r, a)
}))
r.Method(http.MethodGet, tokenURL, http.HandlerFunc(token))
}

func login(w http.ResponseWriter, r *http.Request, a Auth) {
var url string
if a.AccessType == "online" {
url = a.GoogleOAuthConfig.AuthCodeURL(randState, oauth2.AccessTypeOnline)
} else {
url = a.GoogleOAuthConfig.AuthCodeURL(randState, oauth2.AccessTypeOffline)
}

http.Redirect(w, r, url, http.StatusFound)
slog.Info("login request received")
}

func callback(w http.ResponseWriter, r *http.Request, a Auth) {
state := r.FormValue("state")
if state == "" {
sendErr(w, http.StatusBadRequest, errors.New("missing state"))
return
}
if state != randState {
sendErr(w, http.StatusBadRequest, errors.New("invalid state"))
return
}

code := r.FormValue("code")
if code == "" {
sendErr(w, http.StatusBadRequest, errors.New("missing code"))
return
}

ctx := r.Context()
token, err := a.GoogleOAuthConfig.Exchange(ctx, code)
if err != nil {
sendErr(w, http.StatusInternalServerError, err)
return
}

client := a.GoogleOAuthConfig.Client(ctx, token)
resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
if err != nil {
sendErr(w, http.StatusInternalServerError, err)
return
}
defer func() {
_ = resp.Body.Close()
}()

d := json.NewDecoder(resp.Body)

var usr contractus.GoogleUser
err = d.Decode(&usr)
if err != nil {
sendErr(w, http.StatusInternalServerError, err)
return
}

tokenString, err := a.GenerateToken(usr.Email)
if err != nil {
sendErr(w, http.StatusInternalServerError, err)
return
}

cookie := http.Cookie{
Name: "jwt",
Value: tokenString,
MaxAge: 60 * 60 * 24 * 7, // 1 week
Domain: a.Domain,
}
http.SetCookie(w, &cookie)
http.Redirect(w, r, a.Domain+"/docs", http.StatusSeeOther)
}

func token(w http.ResponseWriter, r *http.Request) {
jwt, err := r.Cookie("jwt")
if err != nil {
sendErr(w, http.StatusUnauthorized, Error{"login_again", "try to log in again"})
return
}

send(w, http.StatusOK, struct {
Token string `json:"token"`
}{jwt.Value})
}
71 changes: 71 additions & 0 deletions api/authhandler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package api

import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/go-chi/chi/v5"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
)

func TestToken(t *testing.T) {
r := chi.NewRouter()
RegisterAuthHandler(r, Auth{})

wantToken := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMjN9.PZLMJBT9OIVG2qgp9hQr685oVYFgRgWpcSPmNcw6y7M"
req := httptest.NewRequest(http.MethodGet, "/token", nil)
req.AddCookie(&http.Cookie{
Name: "jwt",
Value: wantToken,
})

resp := httptest.NewRecorder()
r.ServeHTTP(resp, req)

var response struct {
Token string `json:"token"`
}

if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
t.Fatalf("failed to decode response body: %v", err)
}
assert(t, resp.Code, http.StatusOK)
assert(t, response.Token, wantToken)
}

func TestToken_Unauthorized(t *testing.T) {
r := chi.NewRouter()
RegisterAuthHandler(r, Auth{})

req := httptest.NewRequest(http.MethodGet, "/token", nil)

resp := httptest.NewRecorder()
r.ServeHTTP(resp, req)

assert(t, resp.Code, http.StatusUnauthorized)
}

func TestLogin(t *testing.T) {
a := Auth{
GoogleOAuthConfig: &oauth2.Config{
ClientID: "client_id",
ClientSecret: "client_secret",
Endpoint: google.Endpoint,
RedirectURL: "http://localhost:8080/callback",
Scopes: []string{"https://www.googleapis.com/auth/userinfo.email"},
},
}

r := chi.NewRouter()
RegisterAuthHandler(r, a)

req := httptest.NewRequest(http.MethodGet, "/", nil)

resp := httptest.NewRecorder()
r.ServeHTTP(resp, req)

assert(t, resp.Code, http.StatusFound)
}
63 changes: 62 additions & 1 deletion api/docs/api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ info:
servers:
- url: https://contractus-25fea2a1cfb3.herokuapp.com
description: Production server
- url: http://localhost:8080
description: Local server

paths:
/upload:
Expand All @@ -25,6 +27,14 @@ paths:
responses:
'200':
description: OK
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
security:
- cookieAuth: []
/transactions:
get:
summary: Return all transactions
Expand All @@ -45,6 +55,14 @@ paths:
total:
type: integer
description: Number of transactions
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
security:
- cookieAuth: []
/balance/affiliate:
get:
summary: Return balance for an affiliate
Expand Down Expand Up @@ -77,7 +95,14 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'

'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
security:
- cookieAuth: []
/balance/producer:
get:
parameters:
Expand Down Expand Up @@ -110,6 +135,36 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
security:
- cookieAuth: []
/token:
get:
summary: Return a token
tags:
- auth
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
properties:
token:
type: string
description: Token
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
components:
schemas:
ErrorResponse:
Expand Down Expand Up @@ -154,3 +209,9 @@ components:
- venda afiliado
- comissão afiliado
- comissão produtor
securitySchemes:
cookieAuth:
type: apiKey
in: cookie
name: jwt

39 changes: 39 additions & 0 deletions api/token.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package api

import (
"github.com/go-chi/jwtauth/v5"
"github.com/golang-jwt/jwt"
"golang.org/x/oauth2"
)

// Auth have the configuration for Auth endpoints.
type Auth struct {
// Google OAuth2
ClientID string
ClientSecret string
RedirectURL string

// Default domain
Domain string
// JWT secret key
JWTSecretKey string
// Google OAuth2 config struct
GoogleOAuthConfig *oauth2.Config
// Access type
AccessType string // offline(for local) or online(for production)
}

// GenerateToken generates a JWT token for the given email.
func (a *Auth) GenerateToken(email string) (string, error) {
claims := jwt.MapClaims{
"email": email,
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(a.JWTSecretKey))
}

// JWTAuth returns a validated JWT token.
func (a *Auth) JWTAuth() *jwtauth.JWTAuth {
tokenAuth := jwtauth.New("HS256", []byte(a.JWTSecretKey), nil)
return tokenAuth
}
Loading

0 comments on commit 407f832

Please sign in to comment.