diff options
author | Rutger Broekhoff | 2023-12-30 14:00:34 +0100 |
---|---|---|
committer | Rutger Broekhoff | 2023-12-30 14:00:34 +0100 |
commit | f6c92c5e2d87ab1334648b0d1293771de7aae4a5 (patch) | |
tree | 265c3a06accd398a1e0a173af56d7392a5f94a24 /cmd | |
parent | 4f167c0fa991aa9ddb3f0252e23694b3aa6532b1 (diff) | |
download | gitolfs3-f6c92c5e2d87ab1334648b0d1293771de7aae4a5.tar.gz gitolfs3-f6c92c5e2d87ab1334648b0d1293771de7aae4a5.zip |
Implement git-lfs-authenticate
Diffstat (limited to 'cmd')
-rw-r--r-- | cmd/git-lfs-authenticate/main.go | 155 | ||||
-rw-r--r-- | cmd/git-lfs-server/main.go | 32 |
2 files changed, 130 insertions, 57 deletions
diff --git a/cmd/git-lfs-authenticate/main.go b/cmd/git-lfs-authenticate/main.go index 41e2dbc..56b9d78 100644 --- a/cmd/git-lfs-authenticate/main.go +++ b/cmd/git-lfs-authenticate/main.go | |||
@@ -1,8 +1,18 @@ | |||
1 | package main | 1 | package main |
2 | 2 | ||
3 | import ( | 3 | import ( |
4 | "bytes" | ||
5 | "crypto/ed25519" | ||
6 | "encoding/hex" | ||
7 | "encoding/json" | ||
8 | "errors" | ||
4 | "fmt" | 9 | "fmt" |
5 | "os" | 10 | "os" |
11 | "os/exec" | ||
12 | "strings" | ||
13 | "time" | ||
14 | |||
15 | "github.com/golang-jwt/jwt/v5" | ||
6 | ) | 16 | ) |
7 | 17 | ||
8 | func die(msg string, args ...any) { | 18 | func die(msg string, args ...any) { |
@@ -12,37 +22,118 @@ func die(msg string, args ...any) { | |||
12 | os.Exit(1) | 22 | os.Exit(1) |
13 | } | 23 | } |
14 | 24 | ||
25 | func getGitoliteAccess(path, user, gitolitePerm string) bool { | ||
26 | // gitolite access -q: returns only exit code | ||
27 | cmd := exec.Command("gitolite", "access", "-q", path, user, gitolitePerm) | ||
28 | err := cmd.Run() | ||
29 | permGranted := err == nil | ||
30 | var exitErr *exec.ExitError | ||
31 | if err != nil && !errors.As(err, &exitErr) { | ||
32 | die("failed to query access information") | ||
33 | } | ||
34 | return permGranted | ||
35 | } | ||
36 | |||
37 | type gitolfs3Claims struct { | ||
38 | Repository string `json:"repository"` | ||
39 | Permission string `json:"permission"` | ||
40 | } | ||
41 | |||
42 | type customClaims struct { | ||
43 | Gitolfs3 gitolfs3Claims `json:"gitolfs3"` | ||
44 | *jwt.RegisteredClaims | ||
45 | } | ||
46 | |||
47 | type authenticateResponse struct { | ||
48 | Header map[string]string `json:"header"` | ||
49 | // In seconds. | ||
50 | ExpiresIn int64 `json:"expires_in,omitempty"` | ||
51 | // expires_at (RFC3339) could also be used, but we leave it out since we | ||
52 | // don't use it. | ||
53 | } | ||
54 | |||
55 | func wipe(b []byte) { | ||
56 | for i := range b { | ||
57 | b[i] = 0 | ||
58 | } | ||
59 | } | ||
60 | |||
15 | func main() { | 61 | func main() { |
16 | // if len(os.Args) != 3 { | 62 | // Even though not explicitly described in the Git LFS documentation, the |
17 | // die("expected 2 arguments [path, operation], got %d", len(os.Args)-1) | 63 | // git-lfs-authenticate command is expected to either exit succesfully with |
18 | // } | 64 | // exit code 0 and to then print credentials in the prescribed JSON format |
19 | // | 65 | // to standard out. On errors, the command should exit with a non-zero exit |
20 | // path := strings.TrimPrefix(strings.TrimSuffix(os.Args[1], ".git"), "/") | 66 | // code and print the error message in plain text to standard error. See |
21 | // operation := os.Args[2] | 67 | // https://github.com/git-lfs/git-lfs/blob/baf40ac99850a62fe98515175d52df5c513463ec/lfshttp/ssh.go#L76-L117 |
22 | // | 68 | |
23 | // if operation != "download" && operation != "upload" { | 69 | if len(os.Args) != 3 { |
24 | // die("expected operation to be in {upload, download}, got %s", operation) | 70 | die("expected 2 arguments (path, operation), got %d", len(os.Args)-1) |
25 | // } | 71 | } |
26 | // | 72 | |
27 | // user := os.Getenv("GL_USER") | 73 | path := strings.TrimPrefix(strings.TrimSuffix(os.Args[1], ".git"), "/") |
28 | // | 74 | operation := os.Args[2] |
29 | // if user == "" { | 75 | if operation != "download" && operation != "upload" { |
30 | // die("expected Gitolite user env (GL_USER) to be set") | 76 | die("expected operation to be upload or download, got %s", operation) |
31 | // } | 77 | } |
32 | // | 78 | |
33 | // gitolitePerm := "R" | 79 | user := os.Getenv("GL_USER") |
34 | // | 80 | if user == "" { |
35 | // if operation == "upload" { | 81 | die("internal error") |
36 | // gitolitePerm = "W" | 82 | } |
37 | // } | 83 | keyPath := os.Getenv("GITOLFS3_KEY_PATH") |
38 | // | 84 | if keyPath == "" { |
39 | // // gitolite access -q: returns only exit code | 85 | die("internal error") |
40 | // cmd := exec.Command("gitolite", "access", "-q", path, user, gitolitePerm) | 86 | } |
41 | // err := cmd.Run() | 87 | keyStr, err := os.ReadFile(keyPath) |
42 | // permGranted := err == nil | 88 | if err != nil { |
43 | // var exitErr *exec.ExitError | 89 | die("internal error") |
44 | // | 90 | } |
45 | // if err != nil && !errors.As(err, &exitErr) { | 91 | keyStr = bytes.TrimSpace(keyStr) |
46 | // die("failed to query Gitolite access information") | 92 | defer wipe(keyStr) |
47 | // } | 93 | |
94 | if hex.DecodedLen(len(keyStr)) != ed25519.SeedSize { | ||
95 | die("internal error") | ||
96 | } | ||
97 | |||
98 | seed := make([]byte, ed25519.SeedSize) | ||
99 | defer wipe(seed) | ||
100 | if _, err = hex.Decode(seed, keyStr); err != nil { | ||
101 | die("internal error") | ||
102 | } | ||
103 | privateKey := ed25519.NewKeyFromSeed(seed) | ||
104 | |||
105 | if !getGitoliteAccess(path, user, "R") { | ||
106 | die("repository not found") | ||
107 | } | ||
108 | if operation == "upload" && !getGitoliteAccess(path, user, "W") { | ||
109 | // User has read access but not write access | ||
110 | die("forbidden") | ||
111 | } | ||
112 | |||
113 | expiresIn := time.Hour * 24 | ||
114 | claims := customClaims{ | ||
115 | Gitolfs3: gitolfs3Claims{ | ||
116 | Repository: path, | ||
117 | Permission: operation, | ||
118 | }, | ||
119 | RegisteredClaims: &jwt.RegisteredClaims{ | ||
120 | Subject: user, | ||
121 | IssuedAt: jwt.NewNumericDate(time.Now()), | ||
122 | ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiresIn)), | ||
123 | }, | ||
124 | } | ||
125 | |||
126 | token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) | ||
127 | ss, err := token.SignedString(privateKey) | ||
128 | if err != nil { | ||
129 | die("failed to generate token") | ||
130 | } | ||
131 | |||
132 | response := authenticateResponse{ | ||
133 | Header: map[string]string{ | ||
134 | "Authorization": "Bearer " + ss, | ||
135 | }, | ||
136 | ExpiresIn: int64(expiresIn.Seconds()), | ||
137 | } | ||
138 | json.NewEncoder(os.Stdout).Encode(response) | ||
48 | } | 139 | } |
diff --git a/cmd/git-lfs-server/main.go b/cmd/git-lfs-server/main.go index 5e99288..c76c5d9 100644 --- a/cmd/git-lfs-server/main.go +++ b/cmd/git-lfs-server/main.go | |||
@@ -14,7 +14,6 @@ import ( | |||
14 | "path" | 14 | "path" |
15 | "regexp" | 15 | "regexp" |
16 | "slices" | 16 | "slices" |
17 | "strconv" | ||
18 | "strings" | 17 | "strings" |
19 | "time" | 18 | "time" |
20 | "unicode" | 19 | "unicode" |
@@ -54,29 +53,13 @@ type batchRequest struct { | |||
54 | HashAlgo hashAlgo `json:"hash_algo,omitempty"` | 53 | HashAlgo hashAlgo `json:"hash_algo,omitempty"` |
55 | } | 54 | } |
56 | 55 | ||
57 | type RFC3339SecondsTime time.Time | ||
58 | |||
59 | func (t RFC3339SecondsTime) MarshalJSON() ([]byte, error) { | ||
60 | b := make([]byte, 0, len(time.RFC3339)+len(`""`)) | ||
61 | b = append(b, '"') | ||
62 | b = time.Time(t).AppendFormat(b, time.RFC3339) | ||
63 | b = append(b, '"') | ||
64 | return b, nil | ||
65 | } | ||
66 | |||
67 | type SecondDuration time.Duration | ||
68 | |||
69 | func (d SecondDuration) MarshalJSON() ([]byte, error) { | ||
70 | var b []byte | ||
71 | b = strconv.AppendInt(b, int64(time.Duration(d).Seconds()), 10) | ||
72 | return b, nil | ||
73 | } | ||
74 | |||
75 | type batchAction struct { | 56 | type batchAction struct { |
76 | HRef string `json:"href"` | 57 | HRef string `json:"href"` |
77 | Header map[string]string `json:"header,omitempty"` | 58 | Header map[string]string `json:"header,omitempty"` |
78 | ExpiresIn *SecondDuration `json:"expires_in,omitempty"` | 59 | // In seconds. |
79 | ExpiresAt *RFC3339SecondsTime `json:"expires_at,omitempty"` | 60 | ExpiresIn int64 `json:"expires_in,omitempty"` |
61 | // expires_at (RFC3339) could also be used, but we leave it out since we | ||
62 | // don't use it. | ||
80 | } | 63 | } |
81 | 64 | ||
82 | type batchError struct { | 65 | type batchError struct { |
@@ -148,7 +131,6 @@ func makeObjError(obj parsedBatchObject, message string, code int) batchResponse | |||
148 | func (h *handler) handleDownloadObject(ctx context.Context, repo string, obj parsedBatchObject) batchResponseObject { | 131 | func (h *handler) handleDownloadObject(ctx context.Context, repo string, obj parsedBatchObject) batchResponseObject { |
149 | fullPath := path.Join(repo+".git", "lfs/objects", obj.firstByte, obj.secondByte, obj.fullHash) | 132 | fullPath := path.Join(repo+".git", "lfs/objects", obj.firstByte, obj.secondByte, obj.fullHash) |
150 | expiresIn := time.Hour * 24 | 133 | expiresIn := time.Hour * 24 |
151 | expiresInSeconds := SecondDuration(expiresIn) | ||
152 | 134 | ||
153 | info, err := h.mc.StatObject(ctx, h.bucket, fullPath, minio.StatObjectOptions{Checksum: true}) | 135 | info, err := h.mc.StatObject(ctx, h.bucket, fullPath, minio.StatObjectOptions{Checksum: true}) |
154 | if err != nil { | 136 | if err != nil { |
@@ -184,7 +166,7 @@ func (h *handler) handleDownloadObject(ctx context.Context, repo string, obj par | |||
184 | Actions: map[operation]batchAction{ | 166 | Actions: map[operation]batchAction{ |
185 | operationDownload: { | 167 | operationDownload: { |
186 | HRef: presigned.String(), | 168 | HRef: presigned.String(), |
187 | ExpiresIn: &expiresInSeconds, | 169 | ExpiresIn: int64(expiresIn.Seconds()), |
188 | }, | 170 | }, |
189 | }, | 171 | }, |
190 | } | 172 | } |