Skip to content

Commit aab844c

Browse files
committedDec 18, 2018
feat(): use htpasswd to manage basic auth
1 parent 0a096c2 commit aab844c

22 files changed

+420
-270
lines changed
 

‎.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
release/
22
.vscode/
3+
.htpasswd

‎Makefile

+2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
.SILENT :
22

3+
export GO111MODULE=on
4+
35
# Author
46
AUTHOR=github.com/ncarlier
57

‎README.md

+30
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ You can configure the daemon by:
4646
| Variable | Default | Description |
4747
|----------|---------|-------------|
4848
| `APP_LISTEN_ADDR` | `:8080` | HTTP service address |
49+
| `APP_PASSWD_FILE` | `.htpasswd` | Password file for HTTP basic authentication |
4950
| `APP_NB_WORKERS` | `2` | The number of workers to start |
5051
| `APP_HOOK_TIMEOUT` | `10` | Hook maximum delay before timeout (in second) |
5152
| `APP_SCRIPTS_DIR` | `./scripts` | Scripts directory |
@@ -64,6 +65,7 @@ You can configure the daemon by:
6465
| Parameter | Default | Description |
6566
|----------|---------|-------------|
6667
| `-l <address> or --listen <address>` | `:8080` | HTTP service address |
68+
| `-p or --passwd <htpasswd file>` | `.htpasswd` | Password file for HTTP basic authentication
6769
| `-d or --debug` | false | Output debug logs |
6870
| `--nb-workers <workers>` | `2` | The number of workers to start |
6971
| `--scripts <dir>` | `./scripts` | Scripts directory |
@@ -199,6 +201,34 @@ SMTP notification configuration:
199201

200202
The log file will be sent as an GZIP attachment.
201203

204+
### Authentication
205+
206+
You can restrict access to webhooks using HTTP basic authentication.
207+
208+
To activate basic authentication, you have to create a `htpasswd` file:
209+
210+
```bash
211+
$ # create passwd file the user 'api'
212+
$ htpasswd -B -c .htpasswd api
213+
```
214+
This command will ask for a password and store it in the htpawsswd file.
215+
216+
Please note that by default, the daemon will try to load the `.htpasswd` file.
217+
218+
But you can override this behavior by specifying the location of the file:
219+
220+
```bash
221+
$ APP_PASSWD_FILE=/etc/webhookd/users.htpasswd
222+
$ # or
223+
$ webhookd -p /etc/webhookd/users.htpasswd
224+
```
225+
226+
Once configured, you must call webhooks using basic authentication:
227+
228+
```bash
229+
$ curl -u api:test -XPOST "http://localhost:8080/echo?msg=hello"
230+
```
231+
202232
---
203233

204234

‎config.go

-70
This file was deleted.

‎go.mod

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module github.com/ncarlier/webhookd
2+
3+
require golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9

‎go.sum

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9 h1:mKdxBk7AujPs8kU4m80U72y/zjbZ3UcXC7dClwKbUI0=
2+
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=

‎main.go

+11-85
Original file line numberDiff line numberDiff line change
@@ -3,30 +3,17 @@ package main
33
import (
44
"context"
55
"flag"
6-
"fmt"
7-
"log"
86
"net/http"
97
"os"
108
"os/signal"
11-
"sync/atomic"
129
"time"
1310

1411
"github.com/ncarlier/webhookd/pkg/api"
15-
"github.com/ncarlier/webhookd/pkg/auth"
12+
"github.com/ncarlier/webhookd/pkg/config"
1613
"github.com/ncarlier/webhookd/pkg/logger"
1714
"github.com/ncarlier/webhookd/pkg/worker"
1815
)
1916

20-
type key int
21-
22-
const (
23-
requestIDKey key = 0
24-
)
25-
26-
var (
27-
healthy int32
28-
)
29-
3017
func main() {
3118
flag.Parse()
3219

@@ -35,47 +22,25 @@ func main() {
3522
return
3623
}
3724

38-
var authmethod auth.Method
39-
name := *config.Authentication
40-
if _, ok := auth.AvailableMethods[name]; ok {
41-
authmethod = auth.AvailableMethods[name]
42-
if err := authmethod.ParseParam(*config.AuthenticationParam); err != nil {
43-
fmt.Println("Authentication parameter is not valid:", err.Error())
44-
fmt.Println(authmethod.Usage())
45-
os.Exit(2)
46-
}
47-
} else {
48-
fmt.Println("Authentication name is not valid:", name)
49-
os.Exit(2)
50-
}
25+
conf := config.Get()
5126

5227
level := "info"
53-
if *config.Debug {
28+
if *conf.Debug {
5429
level = "debug"
5530
}
5631
logger.Init(level)
5732

5833
logger.Debug.Println("Starting webhookd server...")
59-
logger.Debug.Println("Using Authentication:", name)
60-
authmethod.Init(*config.Debug)
61-
62-
router := http.NewServeMux()
63-
router.Handle("/", api.Index(*config.Timeout, *config.ScriptDir))
64-
router.Handle("/healthz", healthz())
65-
66-
nextRequestID := func() string {
67-
return fmt.Sprintf("%d", time.Now().UnixNano())
68-
}
6934

7035
server := &http.Server{
71-
Addr: *config.ListenAddr,
72-
Handler: authmethod.Middleware()(tracing(nextRequestID)(logging(logger.Debug)(router))),
36+
Addr: *conf.ListenAddr,
37+
Handler: api.NewRouter(config.Get()),
7338
ErrorLog: logger.Error,
7439
}
7540

7641
// Start the dispatcher.
77-
logger.Debug.Printf("Starting the dispatcher (%d workers)...\n", *config.NbWorkers)
78-
worker.StartDispatcher(*config.NbWorkers)
42+
logger.Debug.Printf("Starting the dispatcher (%d workers)...\n", *conf.NbWorkers)
43+
worker.StartDispatcher(*conf.NbWorkers)
7944

8045
done := make(chan bool)
8146
quit := make(chan os.Signal, 1)
@@ -84,7 +49,7 @@ func main() {
8449
go func() {
8550
<-quit
8651
logger.Debug.Println("Server is shutting down...")
87-
atomic.StoreInt32(&healthy, 0)
52+
api.Shutdown()
8853

8954
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
9055
defer cancel()
@@ -96,51 +61,12 @@ func main() {
9661
close(done)
9762
}()
9863

99-
logger.Info.Println("Server is ready to handle requests at", *config.ListenAddr)
100-
atomic.StoreInt32(&healthy, 1)
64+
logger.Info.Println("Server is ready to handle requests at", *conf.ListenAddr)
65+
api.Start()
10166
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
102-
logger.Error.Fatalf("Could not listen on %s: %v\n", *config.ListenAddr, err)
67+
logger.Error.Fatalf("Could not listen on %s: %v\n", *conf.ListenAddr, err)
10368
}
10469

10570
<-done
10671
logger.Debug.Println("Server stopped")
10772
}
108-
109-
func healthz() http.Handler {
110-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
111-
if atomic.LoadInt32(&healthy) == 1 {
112-
w.WriteHeader(http.StatusNoContent)
113-
return
114-
}
115-
w.WriteHeader(http.StatusServiceUnavailable)
116-
})
117-
}
118-
119-
func logging(logger *log.Logger) func(http.Handler) http.Handler {
120-
return func(next http.Handler) http.Handler {
121-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
122-
defer func() {
123-
requestID, ok := r.Context().Value(requestIDKey).(string)
124-
if !ok {
125-
requestID = "unknown"
126-
}
127-
logger.Println(requestID, r.Method, r.URL.Path, r.RemoteAddr, r.UserAgent())
128-
}()
129-
next.ServeHTTP(w, r)
130-
})
131-
}
132-
}
133-
134-
func tracing(nextRequestID func() string) func(http.Handler) http.Handler {
135-
return func(next http.Handler) http.Handler {
136-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
137-
requestID := r.Header.Get("X-Request-Id")
138-
if requestID == "" {
139-
requestID = nextRequestID()
140-
}
141-
ctx := context.WithValue(r.Context(), requestIDKey, requestID)
142-
w.Header().Set("X-Request-Id", requestID)
143-
next.ServeHTTP(w, r.WithContext(ctx))
144-
})
145-
}
146-
}

‎pkg/api/healthz.go

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package api
2+
3+
import (
4+
"net/http"
5+
"sync/atomic"
6+
7+
"github.com/ncarlier/webhookd/pkg/config"
8+
)
9+
10+
var (
11+
healthy int32
12+
)
13+
14+
// Shutdown set API as stopped
15+
func Shutdown() {
16+
atomic.StoreInt32(&healthy, 0)
17+
}
18+
19+
// Start set API as started
20+
func Start() {
21+
atomic.StoreInt32(&healthy, 1)
22+
}
23+
24+
func healthz(conf *config.Config) http.Handler {
25+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
26+
if atomic.LoadInt32(&healthy) == 1 {
27+
w.WriteHeader(http.StatusNoContent)
28+
return
29+
}
30+
w.WriteHeader(http.StatusServiceUnavailable)
31+
})
32+
}

‎pkg/api/api.go renamed to ‎pkg/api/index.go

+5-4
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"strconv"
88
"strings"
99

10+
"github.com/ncarlier/webhookd/pkg/config"
1011
"github.com/ncarlier/webhookd/pkg/logger"
1112
"github.com/ncarlier/webhookd/pkg/tools"
1213
"github.com/ncarlier/webhookd/pkg/worker"
@@ -24,10 +25,10 @@ func atoiFallback(str string, fallback int) int {
2425
return fallback
2526
}
2627

27-
// Index is the main handler of the API.
28-
func Index(timeout int, scrDir string) http.Handler {
29-
defaultTimeout = timeout
30-
scriptDir = scrDir
28+
// index is the main handler of the API.
29+
func index(conf *config.Config) http.Handler {
30+
defaultTimeout = *conf.Timeout
31+
scriptDir = *conf.ScriptDir
3132
return http.HandlerFunc(webhookHandler)
3233
}
3334

‎pkg/api/router.go

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package api
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
"time"
7+
8+
"github.com/ncarlier/webhookd/pkg/auth"
9+
"github.com/ncarlier/webhookd/pkg/config"
10+
"github.com/ncarlier/webhookd/pkg/middleware"
11+
)
12+
13+
// NewRouter creates router with declared routes
14+
func NewRouter(conf *config.Config) *http.ServeMux {
15+
router := http.NewServeMux()
16+
authenticator := auth.NewAuthenticator(conf)
17+
18+
nextRequestID := func() string {
19+
return fmt.Sprintf("%d", time.Now().UnixNano())
20+
}
21+
22+
for _, route := range routes {
23+
var handler http.Handler
24+
25+
handler = route.HandlerFunc(conf)
26+
handler = middleware.Logger(handler)
27+
handler = middleware.Tracing(nextRequestID)(handler)
28+
29+
if authenticator != nil {
30+
handler = middleware.Auth(handler, authenticator)
31+
}
32+
router.Handle(route.Path, handler)
33+
}
34+
35+
return router
36+
}

‎pkg/api/routes.go

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package api
2+
3+
import (
4+
"net/http"
5+
6+
"github.com/ncarlier/webhookd/pkg/config"
7+
)
8+
9+
// HandlerFunc custom function handler
10+
type HandlerFunc func(conf *config.Config) http.Handler
11+
12+
// Route is the structure of an HTTP route definition
13+
type Route struct {
14+
Method string
15+
Path string
16+
HandlerFunc HandlerFunc
17+
}
18+
19+
// Routes is a list of Route
20+
type Routes []Route
21+
22+
var routes = Routes{
23+
Route{
24+
"GET",
25+
"/",
26+
index,
27+
},
28+
Route{
29+
"GET",
30+
"/healtz",
31+
healthz,
32+
},
33+
}

‎pkg/auth/auth.go

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package auth
2+
3+
import (
4+
"net/http"
5+
6+
"github.com/ncarlier/webhookd/pkg/config"
7+
"github.com/ncarlier/webhookd/pkg/logger"
8+
)
9+
10+
// Authenticator is a generic interface to validate an HTTP request
11+
type Authenticator interface {
12+
Validate(r *http.Request) bool
13+
}
14+
15+
// NewAuthenticator creates new authenticator form the configuration
16+
func NewAuthenticator(conf *config.Config) Authenticator {
17+
authenticator, err := NewHtpasswdFromFile(*conf.PasswdFile)
18+
if err != nil {
19+
logger.Debug.Printf("unable to load htpasswd file: \"%s\" (%s)\n", *conf.PasswdFile, err)
20+
return nil
21+
}
22+
return authenticator
23+
}

‎pkg/auth/authmethod.go

-27
This file was deleted.

‎pkg/auth/basic.go

-59
This file was deleted.

‎pkg/auth/htpasswd-file.go

+95
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package auth
2+
3+
import (
4+
"crypto/sha1"
5+
"encoding/base64"
6+
"encoding/csv"
7+
"net/http"
8+
"os"
9+
"regexp"
10+
"strings"
11+
12+
"golang.org/x/crypto/bcrypt"
13+
)
14+
15+
var (
16+
shaRe = regexp.MustCompile(`^{SHA}`)
17+
bcrRe = regexp.MustCompile(`^\$2b\$|^\$2a\$|^\$2y\$`)
18+
)
19+
20+
// HtpasswdFile is a map for usernames to passwords.
21+
type HtpasswdFile struct {
22+
path string
23+
users map[string]string
24+
}
25+
26+
// NewHtpasswdFromFile reads the users and passwords from a htpasswd file and returns them.
27+
func NewHtpasswdFromFile(path string) (*HtpasswdFile, error) {
28+
r, err := os.Open(path)
29+
if err != nil {
30+
return nil, err
31+
}
32+
defer r.Close()
33+
34+
cr := csv.NewReader(r)
35+
cr.Comma = ':'
36+
cr.Comment = '#'
37+
cr.TrimLeadingSpace = true
38+
39+
records, err := cr.ReadAll()
40+
if err != nil {
41+
return nil, err
42+
}
43+
44+
users := make(map[string]string)
45+
for _, record := range records {
46+
users[record[0]] = record[1]
47+
}
48+
49+
return &HtpasswdFile{
50+
path: path,
51+
users: users,
52+
}, nil
53+
}
54+
55+
// Validate HTTP request credentials
56+
func (h *HtpasswdFile) Validate(r *http.Request) bool {
57+
s := strings.SplitN(r.Header.Get("Authorization"), " ", 2)
58+
if len(s) != 2 {
59+
return false
60+
}
61+
62+
b, err := base64.StdEncoding.DecodeString(s[1])
63+
if err != nil {
64+
return false
65+
}
66+
67+
pair := strings.SplitN(string(b), ":", 2)
68+
if len(pair) != 2 {
69+
return false
70+
}
71+
72+
return h.validateCredentials(pair[0], pair[1])
73+
}
74+
75+
func (h *HtpasswdFile) validateCredentials(user string, password string) bool {
76+
pwd, exists := h.users[user]
77+
if !exists {
78+
return false
79+
}
80+
81+
switch {
82+
case shaRe.MatchString(pwd):
83+
d := sha1.New()
84+
_, _ = d.Write([]byte(password))
85+
if pwd[5:] == base64.StdEncoding.EncodeToString(d.Sum(nil)) {
86+
return true
87+
}
88+
case bcrRe.MatchString(pwd):
89+
err := bcrypt.CompareHashAndPassword([]byte(pwd), []byte(password))
90+
if err == nil {
91+
return true
92+
}
93+
}
94+
return false
95+
}

‎pkg/auth/htpasswd-file_test.go

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package auth
2+
3+
import (
4+
"testing"
5+
6+
"github.com/ncarlier/webhookd/pkg/assert"
7+
)
8+
9+
func TestValidateCredentials(t *testing.T) {
10+
htpasswdFile, err := NewHtpasswdFromFile("test.htpasswd")
11+
assert.Nil(t, err, ".htpasswd file should be loaded")
12+
assert.NotNil(t, htpasswdFile, ".htpasswd file should be loaded")
13+
assert.Equal(t, true, htpasswdFile.validateCredentials("foo", "bar"), "credentials should be valid")
14+
assert.Equal(t, false, htpasswdFile.validateCredentials("foo", "bir"), "credentials should not be valid")
15+
}

‎pkg/auth/none.go

-25
This file was deleted.

‎pkg/auth/test.htpasswd

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# htpasswd -B -c test.htpasswd foo
2+
foo:$2y$05$068L1J0kA3FEh8jHSlnluut4gYleWd47Ig/AWztz8/8bQS6tHvtd.

‎pkg/config/config.go

+60
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package config
2+
3+
import (
4+
"flag"
5+
"os"
6+
"strconv"
7+
)
8+
9+
// Config contain global configuration
10+
type Config struct {
11+
ListenAddr *string
12+
NbWorkers *int
13+
Debug *bool
14+
Timeout *int
15+
ScriptDir *string
16+
PasswdFile *string
17+
}
18+
19+
var config = &Config{
20+
ListenAddr: flag.String("listen", getEnv("LISTEN_ADDR", ":8080"), "HTTP service address (e.g.address, ':8080')"),
21+
NbWorkers: flag.Int("nb-workers", getIntEnv("NB_WORKERS", 2), "The number of workers to start"),
22+
Debug: flag.Bool("debug", getBoolEnv("DEBUG", false), "Output debug logs"),
23+
Timeout: flag.Int("timeout", getIntEnv("HOOK_TIMEOUT", 10), "Hook maximum delay before timeout (in second)"),
24+
ScriptDir: flag.String("scripts", getEnv("SCRIPTS_DIR", "scripts"), "Scripts directory"),
25+
PasswdFile: flag.String("passwd", getEnv("PASSWD_FILE", ".htpasswd"), "Password file (encoded with htpasswd)"),
26+
}
27+
28+
func init() {
29+
flag.StringVar(config.ListenAddr, "l", *config.ListenAddr, "HTTP service (e.g address: ':8080')")
30+
flag.BoolVar(config.Debug, "d", *config.Debug, "Output debug logs")
31+
flag.StringVar(config.PasswdFile, "p", *config.PasswdFile, "Password file (encoded with htpasswd)")
32+
}
33+
34+
// Get global configuration
35+
func Get() *Config {
36+
return config
37+
}
38+
39+
func getEnv(key, fallback string) string {
40+
if value, ok := os.LookupEnv("APP_" + key); ok {
41+
return value
42+
}
43+
return fallback
44+
}
45+
46+
func getIntEnv(key string, fallback int) int {
47+
strValue := getEnv(key, strconv.Itoa(fallback))
48+
if value, err := strconv.Atoi(strValue); err == nil {
49+
return value
50+
}
51+
return fallback
52+
}
53+
54+
func getBoolEnv(key string, fallback bool) bool {
55+
strValue := getEnv(key, strconv.FormatBool(fallback))
56+
if value, err := strconv.ParseBool(strValue); err == nil {
57+
return value
58+
}
59+
return fallback
60+
}

‎pkg/middleware/auth.go

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package middleware
2+
3+
import (
4+
"net/http"
5+
6+
"github.com/ncarlier/webhookd/pkg/auth"
7+
)
8+
9+
// Auth is a middleware to checks HTTP request credentials
10+
func Auth(inner http.Handler, authn auth.Authenticator) http.Handler {
11+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
12+
if authn.Validate(r) {
13+
inner.ServeHTTP(w, r)
14+
return
15+
}
16+
w.Header().Set("WWW-Authenticate", `Basic realm="Ah ah ah, you didn't say the magic word"`)
17+
w.WriteHeader(401)
18+
w.Write([]byte("401 Unauthorized\n"))
19+
})
20+
}

‎pkg/middleware/logger.go

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package middleware
2+
3+
import (
4+
"net/http"
5+
"time"
6+
7+
"github.com/ncarlier/webhookd/pkg/logger"
8+
)
9+
10+
type key int
11+
12+
const (
13+
requestIDKey key = 0
14+
)
15+
16+
// Logger is a middleware to log HTTP request
17+
func Logger(next http.Handler) http.Handler {
18+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
19+
start := time.Now()
20+
defer func() {
21+
requestID, ok := r.Context().Value(requestIDKey).(string)
22+
if !ok {
23+
requestID = "unknown"
24+
}
25+
logger.Info.Println(requestID, r.Method, r.URL.Path, r.RemoteAddr, r.UserAgent(), time.Since(start))
26+
}()
27+
next.ServeHTTP(w, r)
28+
})
29+
}

‎pkg/middleware/tracing.go

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package middleware
2+
3+
import (
4+
"context"
5+
"net/http"
6+
)
7+
8+
// Tracing is a middleware to trace HTTP request
9+
func Tracing(nextRequestID func() string) func(http.Handler) http.Handler {
10+
return func(next http.Handler) http.Handler {
11+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
12+
requestID := r.Header.Get("X-Request-Id")
13+
if requestID == "" {
14+
requestID = nextRequestID()
15+
}
16+
ctx := context.WithValue(r.Context(), requestIDKey, requestID)
17+
w.Header().Set("X-Request-Id", requestID)
18+
next.ServeHTTP(w, r.WithContext(ctx))
19+
})
20+
}
21+
}

0 commit comments

Comments
 (0)
Please sign in to comment.