cerrado @ 2eea4b27109e6f958a31890844e2bb69fbc21a48

feat: Add service to handle auth
  1diff --git a/main.go b/main.go
  2index 918b7948a56dd1f13f744cf0965af8ffdae76e44..ab4aee90d096f54cc9235ea4b5e48c27a206774b 100644
  3--- a/main.go
  4+++ b/main.go
  5@@ -2,8 +2,6 @@ package main
  6 
  7 import (
  8 	"context"
  9-	"crypto/rand"
 10-	"encoding/base64"
 11 	"flag"
 12 	"fmt"
 13 	"log/slog"
 14@@ -12,7 +10,6 @@ 	"os/signal"
 15 	"time"
 16 
 17 	"github.com/alecthomas/chroma/v2/styles"
 18-	"golang.org/x/crypto/bcrypt"
 19 
 20 	"git.gabrielgio.me/cerrado/pkg/config"
 21 	"git.gabrielgio.me/cerrado/pkg/handler"
 22@@ -21,9 +18,6 @@ 	"git.gabrielgio.me/cerrado/pkg/worker"
 23 )
 24 
 25 func main() {
 26-	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
 27-	defer stop()
 28-
 29 	if len(os.Args) == 4 && os.Args[1] == "hash" {
 30 		err := hash(os.Args[2], os.Args[3])
 31 		if err != nil {
 32@@ -42,36 +36,34 @@ 		}
 33 		return
 34 	}
 35 
 36-	if err := run(ctx); err != nil {
 37+	if err := run(); err != nil {
 38 		slog.Error("Server", "error", err)
 39 		os.Exit(1)
 40 	}
 41 }
 42 
 43 func hash(username string, password string) error {
 44-	passphrase := fmt.Sprintf("%s:%s", username, password)
 45-	bytes, err := bcrypt.GenerateFromPassword([]byte(passphrase), 14)
 46+	hash, err := service.GenerateHash(username, password)
 47 	if err != nil {
 48 		return err
 49 	}
 50-	fmt.Println(string(bytes))
 51+	fmt.Println(hash)
 52 	return nil
 53 }
 54 
 55 func key() error {
 56-	key := make([]byte, 64)
 57-
 58-	_, err := rand.Read(key)
 59+	en, err := service.GenerateAesKey()
 60 	if err != nil {
 61 		return err
 62 	}
 63-
 64-	en := base64.StdEncoding.EncodeToString(key)
 65 	fmt.Println(en)
 66 	return nil
 67 }
 68 
 69-func run(ctx context.Context) error {
 70+func run() error {
 71+	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
 72+	defer stop()
 73+
 74 	configPath := flag.String("config", "/etc/cerrado.scfg", "File path for the configuration file")
 75 
 76 	flag.Parse()
 77diff --git a/pkg/service/auth.go b/pkg/service/auth.go
 78new file mode 100644
 79index 0000000000000000000000000000000000000000..1fbf4b61724c079a9557bc675846ba94d1ad370e
 80--- /dev/null
 81+++ b/pkg/service/auth.go
 82@@ -0,0 +1,117 @@
 83+package service
 84+
 85+import (
 86+	"bytes"
 87+	"crypto/aes"
 88+	"crypto/cipher"
 89+	"crypto/rand"
 90+	"encoding/base64"
 91+	"fmt"
 92+	"io"
 93+
 94+	"golang.org/x/crypto/bcrypt"
 95+)
 96+
 97+type (
 98+	AuthService struct {
 99+		authRepository authRepository
100+	}
101+
102+	authRepository interface {
103+		GetPassphrase() []byte
104+		GetBase64AesKey() []byte
105+	}
106+)
107+
108+var tokenSeed = []byte("cerrado")
109+
110+func (a *AuthService) CheckAuth(username, password string) bool {
111+	passphrase := a.authRepository.GetPassphrase()
112+	pass := []byte(fmt.Sprintf("%s:%s", username, password))
113+
114+	err := bcrypt.CompareHashAndPassword(passphrase, pass)
115+
116+	return err == nil
117+}
118+
119+func (a *AuthService) IssueToken() ([]byte, error) {
120+	// TODO: do this block only once
121+	base := a.authRepository.GetBase64AesKey()
122+
123+	dbuf, err := base64.StdEncoding.DecodeString(string(base))
124+	if err != nil {
125+		return nil, err
126+	}
127+
128+	block, err := aes.NewCipher(dbuf)
129+	if err != nil {
130+		return nil, err
131+	}
132+
133+	gcm, err := cipher.NewGCM(block)
134+	if err != nil {
135+		return nil, err
136+	}
137+
138+	nonce := make([]byte, gcm.NonceSize())
139+	if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
140+		return nil, err
141+	}
142+
143+	ciphertext := gcm.Seal(nonce, nonce, tokenSeed, nil)
144+
145+	return ciphertext, nil
146+}
147+
148+func (a *AuthService) ValidateToken(token []byte) (bool, error) {
149+	base := a.authRepository.GetBase64AesKey()
150+
151+	dbuf, err := base64.StdEncoding.DecodeString(string(base))
152+	if err != nil {
153+		return false, err
154+	}
155+
156+	block, err := aes.NewCipher(dbuf)
157+	if err != nil {
158+		return false, err
159+	}
160+
161+	gcm, err := cipher.NewGCM(block)
162+	if err != nil {
163+		return false, err
164+	}
165+
166+	nonceSize := gcm.NonceSize()
167+	if len(token) < nonceSize {
168+		return false, fmt.Errorf("ciphertext too short")
169+	}
170+
171+	nonce, ciphertext := token[:nonceSize], token[nonceSize:]
172+	plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
173+	if err != nil {
174+		return false, err
175+	}
176+
177+	return bytes.Equal(tokenSeed, plaintext), nil
178+}
179+
180+func GenerateHash(username, password string) (string, error) {
181+	passphrase := fmt.Sprintf("%s:%s", username, password)
182+	bytes, err := bcrypt.GenerateFromPassword([]byte(passphrase), 14)
183+	if err != nil {
184+		return "", err
185+	}
186+
187+	return string(bytes), nil
188+}
189+
190+func GenerateAesKey() (string, error) {
191+	key := make([]byte, 32)
192+
193+	_, err := rand.Read(key)
194+	if err != nil {
195+		return "", err
196+	}
197+
198+	return base64.StdEncoding.EncodeToString(key), nil
199+}
200diff --git a/pkg/service/auth_test.go b/pkg/service/auth_test.go
201new file mode 100644
202index 0000000000000000000000000000000000000000..06bf76fad52fbd4ac6ba8671142a573aa4bd6dfc
203--- /dev/null
204+++ b/pkg/service/auth_test.go
205@@ -0,0 +1,119 @@
206+// go:build unit
207+
208+package service
209+
210+import (
211+	"testing"
212+)
213+
214+func TestCheck(t *testing.T) {
215+	testCases := []struct {
216+		name       string
217+		passphrase []byte
218+		username   string
219+		password   string
220+		wantError  bool
221+	}{
222+		{
223+			name:       "generated",
224+			passphrase: nil,
225+			username:   "gabrielgio",
226+			password:   "adminadmin",
227+			wantError:  false,
228+		},
229+		{
230+			name:       "static",
231+			passphrase: []byte("$2a$14$W2yT0E6Zm8nTecqipHUQGOLC6PvNjIQqpQTW/MZmD5oqDfaBJnBV6"),
232+			username:   "gabrielgio",
233+			password:   "adminadmin",
234+			wantError:  false,
235+		},
236+		{
237+			name:       "error",
238+			passphrase: []byte("This is not a valid hash"),
239+			username:   "gabrielgio",
240+			password:   "adminadmin",
241+			wantError:  true,
242+		},
243+	}
244+
245+	for _, tc := range testCases {
246+		t.Run(tc.name, func(t *testing.T) {
247+			mock := &mockAuthRepository{
248+				username:   tc.username,
249+				password:   tc.password,
250+				passphrase: tc.passphrase,
251+			}
252+
253+			service := AuthService{authRepository: mock}
254+
255+			if service.CheckAuth(tc.username, tc.password) == tc.wantError {
256+				t.Errorf("Invalid result, wanted %t got %t", tc.wantError, !tc.wantError)
257+			}
258+		})
259+	}
260+}
261+
262+func TestValidate(t *testing.T) {
263+	testCases := []struct {
264+		name   string
265+		aesKey []byte
266+	}{
267+		{
268+			name:   "generated",
269+			aesKey: nil,
270+		},
271+		{
272+			name:   "static",
273+			aesKey: []byte("RTGkmunKmi5agh7jaqENunG2zI/godnkqhHaHyX/AVg="),
274+		},
275+	}
276+
277+	for _, tc := range testCases {
278+		t.Run(tc.name, func(t *testing.T) {
279+			mock := &mockAuthRepository{
280+				aesKey: tc.aesKey,
281+			}
282+
283+			service := AuthService{authRepository: mock}
284+
285+			token, err := service.IssueToken()
286+			if err != nil {
287+				t.Fatalf("Error issuing token: %s", err.Error())
288+			}
289+
290+			v, err := service.ValidateToken(token)
291+			if err != nil {
292+				t.Fatalf("Error validating token: %s", err.Error())
293+			}
294+
295+			if !v {
296+				t.Error("Invalid token generated")
297+			}
298+		})
299+	}
300+}
301+
302+type mockAuthRepository struct {
303+	username   string
304+	password   string
305+	passphrase []byte
306+
307+	aesKey []byte
308+}
309+
310+func (m *mockAuthRepository) GetPassphrase() []byte {
311+	if m.passphrase == nil {
312+		hash, _ := GenerateHash(m.username, m.password)
313+		m.passphrase = []byte(hash)
314+	}
315+	return m.passphrase
316+}
317+
318+func (m *mockAuthRepository) GetBase64AesKey() []byte {
319+	if m.aesKey == nil {
320+		key, _ := GenerateAesKey()
321+		m.aesKey = []byte(key)
322+	}
323+	return m.aesKey
324+}