Skip to content

Commit

Permalink
feat: auth package
Browse files Browse the repository at this point in the history
  • Loading branch information
shellfly committed Mar 23, 2023
1 parent 6987737 commit 34ee97a
Show file tree
Hide file tree
Showing 22 changed files with 1,221 additions and 15 deletions.
2 changes: 1 addition & 1 deletion examples/auth/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import (
"log"
"net/http"

"github.com/rest-go/auth"
"github.com/rest-go/rest/pkg/auth"
"github.com/rest-go/rest/pkg/server"
)

Expand Down
5 changes: 2 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,17 @@ go 1.18

require (
github.com/go-sql-driver/mysql v1.7.0
github.com/golang-jwt/jwt/v4 v4.4.3
github.com/jackc/pgconn v1.13.0
github.com/jackc/pgx/v5 v5.2.0
github.com/rest-go/auth v0.1.1
github.com/stretchr/testify v1.8.1
golang.org/x/crypto v0.5.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.20.0
)

require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/golang-jwt/jwt/v4 v4.4.3 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
github.com/jackc/pgio v1.0.0 // indirect
Expand All @@ -27,7 +27,6 @@ require (
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect
github.com/rogpeppe/go-internal v1.6.1 // indirect
golang.org/x/crypto v0.5.0 // indirect
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect
golang.org/x/sys v0.4.0 // indirect
golang.org/x/text v0.6.0 // indirect
Expand Down
6 changes: 1 addition & 5 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -76,16 +76,12 @@ github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rest-go/auth v0.1.0 h1:wsJsKilaJUdl4jvYKD00kOOaoGJ0469FMpz35WenZh8=
github.com/rest-go/auth v0.1.0/go.mod h1:i9RnkB56gcydO2lOMVfRxBlJ6GKV4xY0rJW4itIk3cw=
github.com/rest-go/auth v0.1.1 h1:dTs4RHRkrM2WpvpRvT2Ni24YaQ6iYdQMLIIGh2Uk/V8=
github.com/rest-go/auth v0.1.1/go.mod h1:yLuzwqpfapKIXU6jACopEZBpKJL61GcQ7SnXghvTIAY=
github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
Expand Down
2 changes: 1 addition & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (
"strconv"
"time"

"github.com/rest-go/auth"
"github.com/rest-go/rest/pkg/auth"

"github.com/rest-go/rest/pkg/log"
"github.com/rest-go/rest/pkg/server"
Expand Down
88 changes: 88 additions & 0 deletions pkg/auth/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Auth package


Auth is a RESTFul Authentication and Authorization package for Golang HTTP apps.

It handles the common tasks of registration, logging in, logging out, JWT token generation, and JWT token verification.

## Usage
import `auth` to your app, create `auth.Handler` and `auth.Middleware` based on requirements.
```go
package main

import (
"log"
"net/http"

"github.com/rest-go/rest/pkg/auth"
)

func handle(w http.ResponseWriter, req *http.Request) {
user := auth.GetUser(req)
if user.IsAnonymous() {
w.WriteHeader(http.StatusUnauthorized)
} else {
w.WriteHeader(http.StatusOK)
}
}

func main() {
dbURL := "sqlite://my.db"
jwtSecret := "my secret"
authHandler, err := auth.NewHandler(dbURL, []byte(jwtSecret))
if err != nil {
log.Fatal(err)
}
http.Handle("/auth/", authHandler)

middleware := auth.NewMiddleware([]byte(jwtSecret))
http.Handle("/", middleware(http.HandlerFunc(handle)))
log.Fatal(http.ListenAndServe(":8000", nil)) //nolint:gosec
}
```

## Setup database

Send a `POST` request to `/auth/setup` to set up database tables for users. This
will also create an admin user account and return the username and password in
the response.

```bash
$ curl -XPOST "localhost:8000/auth/setup"
```

## Auth handler

The `Auth` struct implements the `http.Hanlder` interface and provides the below endpoints for user management.

1. Register

```bash
$ curl -XPOST "localhost:8000/auth/register" -d '{"username":"hello", "password": "world"}'
```

2. Login

```bash
$ curl -XPOST "localhost:8000/auth/login" -d '{"username":"hello", "password": "world"}'
```

3. Logout

Currently, the authentication mechanism is based on JWT token only, logout is a no-op on the
server side, and the client should clear the token by itself.

```bash
$ curl -XPOST "localhost:8000/auth/logout"
```

## Auth middleware and `GetUser`

Auth middleware will parse JWT token in the HTTP header, and when successful,
set the user in the request context, the `GetUser` method can be used to get the
user from the request.

``` go
user := auth.GetUser(req)
```

23 changes: 23 additions & 0 deletions pkg/auth/action.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package auth

type Action int

const (
ActionCreate Action = iota
ActionRead
ActionUpdate
ActionDelete
ActionReadMine // read with ?mine query, usually filter by user_id field
)

var actionToStr = map[Action]string{
ActionCreate: "create",
ActionRead: "read",
ActionUpdate: "update",
ActionDelete: "delete",
ActionReadMine: "read_mine",
}

func (a Action) String() string {
return actionToStr[a]
}
64 changes: 64 additions & 0 deletions pkg/auth/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// package auth provide restful interface for authentication
package auth

import (
"context"
"errors"
"fmt"

"github.com/golang-jwt/jwt/v4"
"github.com/rest-go/rest/pkg/sql"
)

var primaryKeySQL = map[string]string{
"postgres": "BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY",
"mysql": "BIGINT PRIMARY KEY AUTO_INCREMENT",
"sqlite": "INTEGER PRIMARY KEY",
}

// GenJWTToken generate and return jwt token
func GenJWTToken(secret []byte, data map[string]any) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(data))
return token.SignedString(secret)
}

// ParseJWTToken parse tokenString and return data if token is valid
func ParseJWTToken(secret []byte, tokenString string) (map[string]any, error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// Don't forget to validate the alg is what you expect:
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return secret, nil
})
if err != nil {
return nil, err
}

if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
return map[string]any(claims), nil
}

return nil, errors.New("invalid token")
}

// Setup setup database tables and create an admin user account
func Setup(db *sql.DB) (username, password string, err error) {
if isSetupDone(db) {
err = errors.New("setup is already done before")
return
}
username, password, err = setupUsers(db)
if err != nil {
return
}
err = setupPolicies(db)
return
}

func isSetupDone(db *sql.DB) bool {
ctx, cancel := context.WithTimeout(context.Background(), sql.DefaultTimeout)
defer cancel()
_, err := db.ExecQuery(ctx, "SELECT 1 FROM auth_users")
return err == nil
}
72 changes: 72 additions & 0 deletions pkg/auth/auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// package auth provide restful interface for authentication
package auth

import (
"os"
"reflect"
"testing"
"time"

"github.com/stretchr/testify/assert"

"github.com/rest-go/rest/pkg/log"
"github.com/rest-go/rest/pkg/sql"
)

func TestJWTToken(t *testing.T) {
t.Run("happy path", func(t *testing.T) {
data := map[string]any{
"a": "b",
}
token, err := GenJWTToken([]byte(testSecret), data)
assert.Nil(t, err)

parsedData, err := ParseJWTToken([]byte(testSecret), token)
assert.Nil(t, err)
assert.True(t, reflect.DeepEqual(data, parsedData))
})

t.Run("invalid token", func(t *testing.T) {
data := map[string]any{
"a": "b",
}
token, err := GenJWTToken([]byte(testSecret), data)
assert.Nil(t, err)

parsedData, err := ParseJWTToken([]byte(testSecret), token[:len(token)-1])
assert.Nil(t, parsedData)
assert.NotNil(t, err)
t.Log(err)
})

t.Run("expired token", func(t *testing.T) {
data := map[string]any{
"a": "b",
"exp": time.Now().Add(-24 * time.Hour).Unix(),
}
token, err := GenJWTToken([]byte(testSecret), data)
assert.Nil(t, err)

parsedData, err := ParseJWTToken([]byte(testSecret), token)
assert.Nil(t, parsedData)
assert.NotNil(t, err)
t.Log(err)
})
}

func TestSetup(t *testing.T) {
file, err := os.CreateTemp(".", "test-")
if err != nil {
log.Fatal(err)
}
defer os.Remove(file.Name())
db, err := sql.Open("sqlite://" + file.Name())
assert.Nil(t, err)
_, _, err = Setup(db)
assert.Nil(t, err)

// call Setup again will return an error
_, _, err = Setup(db)
assert.NotNil(t, err)
t.Log(err)
}
31 changes: 31 additions & 0 deletions pkg/auth/examples/handler/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package main

import (
"log"
"net/http"

"github.com/rest-go/rest/pkg/auth"
)

func handle(w http.ResponseWriter, req *http.Request) {
user := auth.GetUser(req)
if user.IsAnonymous() {
w.WriteHeader(http.StatusUnauthorized)
} else {
w.WriteHeader(http.StatusOK)
}
}

func main() {
dbURL := "sqlite://my.db"
jwtSecret := "my secret"
authHandler, err := auth.NewHandler(dbURL, []byte(jwtSecret))
if err != nil {
log.Fatal(err)
}
middleware := auth.NewMiddleware([]byte(jwtSecret))

http.Handle("/auth/", authHandler)
http.Handle("/", middleware(http.HandlerFunc(handle)))
log.Fatal(http.ListenAndServe(":8000", nil)) //nolint:gosec
}
25 changes: 25 additions & 0 deletions pkg/auth/examples/middleware/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package main

import (
"log"
"net/http"

"github.com/rest-go/rest/pkg/auth"
)

func handle(w http.ResponseWriter, req *http.Request) {
user := auth.GetUser(req)
if user.IsAnonymous() {
w.WriteHeader(http.StatusUnauthorized)
} else {
w.WriteHeader(http.StatusOK)
}
}

func main() {
jwtSecret := "my secret"
middleware := auth.NewMiddleware([]byte(jwtSecret))

http.Handle("/", middleware(http.HandlerFunc(handle)))
log.Fatal(http.ListenAndServe(":8000", nil)) //nolint:gosec
}
Loading

0 comments on commit 34ee97a

Please sign in to comment.