Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ feat: authentication #17

Merged
merged 1 commit into from
Sep 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading