commit 0cb77cc6c78407ea1de80d1db0a0ae0757d9967c Author: Matthieu Lamalle Date: Thu Jul 16 10:51:50 2020 +0200 first commit diff --git a/.env.dist b/.env.dist new file mode 100644 index 0000000..88c6736 --- /dev/null +++ b/.env.dist @@ -0,0 +1,11 @@ +## Server +web_adress=":3001" + +## Postgres +db_user="jwtserver" +db_pass="jwtserver" +db_name="jwtserver" +db_host="localhost" + +## JWT +token_password="NotSoSecretJwtSecretPassword" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c5d14b8 --- /dev/null +++ b/Makefile @@ -0,0 +1,31 @@ +GOCMD=go +GOBUILD=$(GOCMD) build +GOCLEAN=$(GOCMD) clean +GOTEST=$(GOCMD) test +GOGET=$(GOCMD) get +BINARY_NAME=jwtserver +BINARY_UNIX=$(BINARY_NAME)_unix + +build: + $(GOBUILD) -o $(BINARY_NAME) -v ./cmd/jwtserver/main.go + +build-docker: + docker-compose build + +up: build-docker + docker-compose up + +init: clean vendor + +run: + $(GOBUILD) -o $(BINARY_NAME) -v ./cmd/... + ./$(BINARY_NAME) + +vendor: tidy + go mod vendor + +clean: + $(GOCLEAN) ./... + @rm -rf ./vendor + @rm -f $(BINARY_NAME) + @rm -f $(BINARY_UNIX) \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..8d2e453 --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# Go-JWTServer + +Serveur de gestion d'utilisateur et fournissant un token jwt. + +Dépendances: docker, docker-compose + +## Configuration +Editer le ficher `.env` + +``` +## Server +web_adress=":3001" + +## Postgres +db_user="jwtserver" +db_pass="jwtserver" +db_name="jwtserver" +db_host="localhost" + +## JWT +token_password="NotSoSecretJwtSecretPassword" +``` + +## API +#### Enregistrer un utilisateur +``` +POST {{host}}/api/user/new +content-type: application/json + +{ + "email": "test@test.com", + "password": "test" +} +``` +#### Authentifier un utilisateur +``` +POST {{host}}/api/user/login +content-type: application/json + +{ + "email": "test@test.com", + "password": "test" +} +``` +#### Réponse +``` +{ + "account": { + "ID": 1, + "CreatedAt": "2020-07-15T14:08:22.288502Z", + "UpdatedAt": "2020-07-15T14:08:22.288502Z", + "DeletedAt": null, + "email": "test@test.com", + "password": "", + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVc2VySWQiOjF9.-bV_jRNcykDMsI-vjxKbiNBsEwqSfDspEEjBTE2nds8" + }, + "message": "Logged In", + "status": true +} +``` + +## Executer le serveur +Lancer le conteneur postgres +```make up``` + + +Dans une autre console, lancer le serveur jwt +```make run``` \ No newline at end of file diff --git a/cmd/jwtserver/main.go b/cmd/jwtserver/main.go new file mode 100644 index 0000000..e942e40 --- /dev/null +++ b/cmd/jwtserver/main.go @@ -0,0 +1,39 @@ +package main + +import ( + "flag" + "fmt" + "log" + "net/http" + "os" + + "forges.cadoles.com/go-jwtserver/internal/router" + "github.com/joho/godotenv" +) + +var ( + configFile = "" + dumpConfig = false + version = false + confWebadress = "" +) + +func init() { + e := godotenv.Load() //Load .env file + if e != nil { + fmt.Print(e) + } + confWebadress = os.Getenv("web_adress") +} + +func main() { + flag.Parse() + + router := router.InitializeRouter() + // Passing -routes to the program will generate docs for the above + // router definition. See the `routes.json` file in this folder for + // the output. + log.Printf("listening on '%s'", confWebadress) + log.Fatal(http.ListenAndServe(confWebadress, router)) + +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5ce6c23 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +version: '2.4' +services: + postgres: + build: + context: ./misc/containers/postgres + args: + - HTTP_PROXY=${HTTP_PROXY} + - HTTPS_PROXY=${HTTPS_PROXY} + - http_proxy=${http_proxy} + - https_proxy=${https_proxy} + environment: + - POSTGRES_PASSWORD=postgres + ports: + - 5432:5432 + volumes: + - postgres_data:/var/lib/postgresql/data +volumes: + postgres_data: \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..33ee78d --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module forges.cadoles.com/go-jwtserver + +go 1.14 + +require ( + github.com/dgrijalva/jwt-go v3.2.0+incompatible + github.com/go-chi/chi v4.1.2+incompatible + github.com/jinzhu/gorm v1.9.14 + github.com/joho/godotenv v1.3.0 + golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..014553c --- /dev/null +++ b/go.sum @@ -0,0 +1,41 @@ +github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc= +github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= +github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd h1:83Wprp6ROGeiHFAP8WJdI2RoxALQYgdllERc3N5N2DM= +github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= +github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DPaBjB8zlTR87/ElzFsnQfuHnVUVqpZZIcV5Y= +github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0= +github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec= +github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= +github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs= +github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY= +github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/jinzhu/gorm v1.9.14 h1:Kg3ShyTPcM6nzVo148fRrcMO6MNKuqtOUwnzqMgVniM= +github.com/jinzhu/gorm v1.9.14/go.mod h1:G3LB3wezTOWM2ITLzPxEXgSkOXAntiLHS7UdBefADcs= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.0.1 h1:HjfetcXq097iXP0uoPCdnM4Efp5/9MsM0/M+XOTeR3M= +github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= +github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= +github.com/lib/pq v1.1.1 h1:sJZmqHoEaY7f+NPP8pgLB/WxulyR3fewgCM2qaSlBb4= +github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/mattn/go-sqlite3 v1.14.0 h1:mLyGNKR8+Vv9CAU7PphKa2hkEqxxhn8i32J6FPj1/QA= +github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899 h1:DZhuSZLsGlFL4CmhA8BcRA0mnthyA/nZ00AqCUo7vHg= +golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e h1:3G+cUijn7XD+S4eJFddp53Pv7+slrESplyjG25HgL+k= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/internal/jwtcontroller/accounts.go b/internal/jwtcontroller/accounts.go new file mode 100644 index 0000000..ebe5fb2 --- /dev/null +++ b/internal/jwtcontroller/accounts.go @@ -0,0 +1,167 @@ +package jwtcontroller + +import ( + "encoding/json" + "log" + "net/http" + + "github.com/dgrijalva/jwt-go" + + "strings" + + "github.com/jinzhu/gorm" + + "os" + + "golang.org/x/crypto/bcrypt" +) + +/* +JWT claims struct +*/ +type Token struct { + UserId uint + jwt.StandardClaims +} + +//a struct to rep user account +type Account struct { + gorm.Model + Email string `json:"email"` + Password string `json:"password"` + Token string `json:"token";sql:"-"` +} + +//Validate incoming user details... +func (account *Account) Validate() (map[string]interface{}, bool) { + + if !strings.Contains(account.Email, "@") { + return Message(false, "Email address is required"), false + } + + if len(account.Password) < 1 { + return Message(false, "Password is required"), false + } + + //Email must be unique + temp := &Account{} + + //check for errors and duplicate emails + err := GetDB().Table("accounts").Where("email = ?", account.Email).First(temp).Error + if err != nil && err != gorm.ErrRecordNotFound { + return Message(false, "Connection error. Please retry"), false + } + if temp.Email != "" { + return Message(false, "Email address already in use by another user."), false + } + + return Message(false, "Requirement passed"), true +} + +func (account *Account) Create() map[string]interface{} { + + if resp, ok := account.Validate(); !ok { + return resp + } + + hashedPassword, _ := bcrypt.GenerateFromPassword([]byte(account.Password), bcrypt.DefaultCost) + account.Password = string(hashedPassword) + + GetDB().Create(account) + + if account.ID <= 0 { + return Message(false, "Failed to create account, connection error.") + } + + //Create new JWT token for the newly registered account + tk := &Token{UserId: account.ID} + token := jwt.NewWithClaims(jwt.GetSigningMethod("HS256"), tk) + tokenString, _ := token.SignedString([]byte(os.Getenv("token_password"))) + account.Token = tokenString + + account.Password = "" //delete password + + response := Message(true, "Account has been created") + response["account"] = account + return response +} + +func Login(email, password string) map[string]interface{} { + + account := &Account{} + err := GetDB().Table("accounts").Where("email = ?", email).First(account).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return Message(false, "Email address not found") + } + return Message(false, "Connection error. Please retry") + } + + err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password)) + if err != nil && err == bcrypt.ErrMismatchedHashAndPassword { //Password does not match! + return Message(false, "Invalid login credentials. Please try again") + } + //Worked! Logged In + account.Password = "" + + //Create JWT token + tk := &Token{UserId: account.ID} + token := jwt.NewWithClaims(jwt.GetSigningMethod("HS256"), tk) + tokenString, _ := token.SignedString([]byte(os.Getenv("token_password"))) + account.Token = tokenString //Store the token in the response + + resp := Message(true, "Logged In") + resp["account"] = account + return resp +} + +func GetUser(u uint) *Account { + + acc := &Account{} + GetDB().Table("accounts").Where("id = ?", u).First(acc) + if acc.Email == "" { //User not found! + return nil + } + + acc.Password = "" + return acc +} + +var CreateAccount = func(w http.ResponseWriter, r *http.Request) { + + account := &Account{} + err := json.NewDecoder(r.Body).Decode(account) //decode the request body into struct and failed if any error occur + log.Println(err) + if err != nil { + Respond(w, Message(false, "Invalid request")) + return + } + + resp := account.Create() //Create account + Respond(w, resp) +} + +var Authenticate = func(w http.ResponseWriter, r *http.Request) { + + account := &Account{} + err := json.NewDecoder(r.Body).Decode(account) //decode the request body into struct and failed if any error occur + log.Println(err) + if err != nil { + Respond(w, Message(false, "Invalid request")) + return + } + + resp := Login(account.Email, account.Password) + Respond(w, resp) +} + +func Message(status bool, message string) map[string]interface{} { + log.Println(message) + return map[string]interface{}{"status": status, "message": message} +} + +func Respond(w http.ResponseWriter, data map[string]interface{}) { + log.Println(data) + w.Header().Add("Content-Type", "application/json") + json.NewEncoder(w).Encode(data) +} diff --git a/internal/jwtcontroller/jwt.go b/internal/jwtcontroller/jwt.go new file mode 100644 index 0000000..958b32f --- /dev/null +++ b/internal/jwtcontroller/jwt.go @@ -0,0 +1,81 @@ +package jwtcontroller + +import ( + "context" + "fmt" + "log" + + "net/http" + "os" + "strings" + + jwt "github.com/dgrijalva/jwt-go" +) + +// JwtAuthentication is a Jwt Auth controller with postgres database +var JwtAuthentication = func(next http.Handler) http.Handler { + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + + notAuth := []string{"/api/user/new", "/api/user/login"} //List of endpoints that doesn't require auth + requestPath := r.URL.Path //current request path + + //check if request does not need authentication, serve the request if it doesn't need it + for _, value := range notAuth { + + if value == requestPath { + next.ServeHTTP(w, r) + return + } + } + + response := make(map[string]interface{}) + tokenHeader := r.Header.Get("Authorization") //Grab the token from the header + + if tokenHeader == "" { //Token is missing, returns with error code 403 Unauthorized + response = Message(false, "Missing auth token") + w.WriteHeader(http.StatusForbidden) + w.Header().Add("Content-Type", "application/json") + Respond(w, response) + return + } + + splitted := strings.Split(tokenHeader, " ") //The token normally comes in format `Bearer {token-body}`, we check if the retrieved token matched this requirement + if len(splitted) != 2 { + response = Message(false, "Invalid/Malformed auth token") + w.WriteHeader(http.StatusForbidden) + w.Header().Add("Content-Type", "application/json") + Respond(w, response) + return + } + + tokenPart := splitted[1] //Grab the token part, what we are truly interested in + tk := &Token{} + log.Println(splitted) + token, err := jwt.ParseWithClaims(tokenPart, tk, func(token *jwt.Token) (interface{}, error) { + return []byte(os.Getenv("token_password")), nil + }) + + if err != nil { //Malformed token, returns with http code 403 as usual + response = Message(false, "Malformed authentication token") + w.WriteHeader(http.StatusForbidden) + w.Header().Add("Content-Type", "application/json") + Respond(w, response) + return + } + + if !token.Valid { //Token is invalid, maybe not signed on this server + response = Message(false, "Token is not valid.") + w.WriteHeader(http.StatusForbidden) + w.Header().Add("Content-Type", "application/json") + Respond(w, response) + return + } + + //Everything went well, proceed with the request and set the caller to the user retrieved from the parsed token + fmt.Sprintf("User %", tk) //Useful for monitoring + ctx := context.WithValue(r.Context(), "user", tk.UserId) + r = r.WithContext(ctx) + next.ServeHTTP(w, r) //proceed in the middleware chain! + }) +} diff --git a/internal/jwtcontroller/postgres.go b/internal/jwtcontroller/postgres.go new file mode 100644 index 0000000..c5cbcab --- /dev/null +++ b/internal/jwtcontroller/postgres.go @@ -0,0 +1,41 @@ +package jwtcontroller + +import ( + "fmt" + "os" + + "github.com/jinzhu/gorm" + _ "github.com/jinzhu/gorm/dialects/postgres" + "github.com/joho/godotenv" +) + +var db *gorm.DB //database + +func init() { + + e := godotenv.Load() //Load .env file + if e != nil { + fmt.Print(e) + } + + username := os.Getenv("db_user") + password := os.Getenv("db_pass") + dbName := os.Getenv("db_name") + dbHost := os.Getenv("db_host") + + dbUri := fmt.Sprintf("host=%s user=%s dbname=%s sslmode=disable password=%s", dbHost, username, dbName, password) //Build connection string + fmt.Println(dbUri) + + conn, err := gorm.Open("postgres", dbUri) + if err != nil { + fmt.Print(err) + } + + db = conn + db.Debug().AutoMigrate(&Account{}) //Database migration +} + +//returns a handle to the DB object +func GetDB() *gorm.DB { + return db +} diff --git a/internal/router/router.go b/internal/router/router.go new file mode 100644 index 0000000..9a09863 --- /dev/null +++ b/internal/router/router.go @@ -0,0 +1,29 @@ +package router + +import ( + "time" + + "forges.cadoles.com/go-jwtserver/internal/jwtcontroller" + "github.com/go-chi/chi" + "github.com/go-chi/chi/middleware" +) + +func InitializeRouter() chi.Router { + r := chi.NewRouter() + // Define base middlewares + r.Use(middleware.RequestID) + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + r.Use(middleware.URLFormat) + + r.Use(middleware.Timeout(60 * time.Second)) + + r.Route("/api/", func(r chi.Router) { + // Middleware routes + r.Post("/user/new", jwtcontroller.CreateAccount) + r.Post("/user/login", jwtcontroller.Authenticate) + + }) + + return r +} diff --git a/jwtserver b/jwtserver new file mode 100755 index 0000000..4803179 Binary files /dev/null and b/jwtserver differ diff --git a/misc/containers/postgres/Dockerfile b/misc/containers/postgres/Dockerfile new file mode 100644 index 0000000..f4ea954 --- /dev/null +++ b/misc/containers/postgres/Dockerfile @@ -0,0 +1,3 @@ +FROM postgres:12-alpine + +COPY ./initdb.d /docker-entrypoint-initdb.d \ No newline at end of file diff --git a/misc/containers/postgres/initdb.d/init-databases.sh b/misc/containers/postgres/initdb.d/init-databases.sh new file mode 100644 index 0000000..c236a11 --- /dev/null +++ b/misc/containers/postgres/initdb.d/init-databases.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +set -e + +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL + CREATE USER jwtserver WITH ENCRYPTED PASSWORD 'jwtserver'; + CREATE DATABASE jwtserver; + GRANT ALL PRIVILEGES ON DATABASE jwtserver TO jwtserver; + ALTER DATABASE jwtserver OWNER TO jwtserver; +EOSQL \ No newline at end of file