Add everyone system user for anonymous registry pull.
Reserved PUBLIC ACLs can grant unauthenticated pull while blocking login, deletion, and anonymous push/delete. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
4f99a99083
commit
3fa4ae2b66
12 files changed
+188
-31
No files matched your search
@@ -76,6 +76,19 @@ UI **사용자** 메뉴에서 계정을 만들고 ACL을 지정합니다.
|
|||||||
|------|------|
|
|------|------|
|
||||||
| `ADMIN` | 전체 pull/push/delete, 사용자 관리 |
|
| `ADMIN` | 전체 pull/push/delete, 사용자 관리 |
|
||||||
| `USER` | 할당된 ACL만 |
|
| `USER` | 할당된 ACL만 |
|
||||||
|
| `everyone` | 예약된 PUBLIC 시스템 사용자. 할당된 ACL을 계정 없이 pull |
|
||||||
|
|
||||||
|
### 계정 없는 공개 pull
|
||||||
|
|
||||||
|
앱이 처음 시작될 때 `everyone` 시스템 사용자가 자동 생성됩니다. UI의 **사용자 → everyone 편집**에서 공개할 저장소 패턴을 추가하세요.
|
||||||
|
|
||||||
|
예를 들어 `demo/*`를 추가하면 로그인하지 않고 다음 명령을 실행할 수 있습니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull docker.sejong.dev/demo/alpine:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
`everyone`은 로그인·삭제·역할 변경이 불가능하며, ACL에는 pull만 적용됩니다. 익명 push/delete는 설정값과 관계없이 차단됩니다. 민감한 이미지를 `*` 패턴으로 공개하지 않도록 주의하세요.
|
||||||
|
|
||||||
ACL 패턴 예:
|
ACL 패턴 예:
|
||||||
|
|
||||||
@@ -106,9 +119,9 @@ npm run dev
|
|||||||
|
|
||||||
## 인증 흐름
|
## 인증 흐름
|
||||||
|
|
||||||
1. `docker login` / push / pull → Registry가 `401` + `WWW-Authenticate` 반환
|
1. push / pull → Registry가 `401` + `WWW-Authenticate` 반환
|
||||||
2. 클라이언트가 `AUTH_PUBLIC_URL/auth/token`에 Basic Auth로 토큰 요청
|
2. 클라이언트가 `AUTH_PUBLIC_URL/auth/token`에 토큰 요청
|
||||||
3. Nuxt가 사용자·ACL을 확인해 RS256 JWT 발급
|
3. Nuxt가 로그인 계정의 ACL을 확인하고, 무인증 요청은 `everyone` ACL의 pull만 확인해 RS256 JWT 발급
|
||||||
4. Registry가 `certs/token.crt`로 JWT 검증
|
4. Registry가 `certs/token.crt`로 JWT 검증
|
||||||
|
|
||||||
Token Auth 키(`certs/token.key`, `certs/token.crt`)는 Compose `certs` 서비스가 없으면 자동 생성합니다. 새로 발급하려면:
|
Token Auth 키(`certs/token.key`, `certs/token.crt`)는 Compose `certs` 서비스가 없으면 자동 생성합니다. 새로 발급하려면:
|
||||||
|
|||||||
+10
-2
@@ -87,7 +87,15 @@ REGISTRY_PUBLIC_URL={{ registryHost }}</code></pre>
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>사용자별 저장소 접근 범위를 설정합니다.</CardDescription>
|
<CardDescription>사용자별 저장소 접근 범위를 설정합니다.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent class="space-y-4">
|
||||||
|
<Alert>
|
||||||
|
<Globe2 class="size-4" />
|
||||||
|
<AlertTitle>계정 없는 pull</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
사용자 메뉴의 <code class="font-mono">everyone</code>에 저장소 패턴을 추가하면 해당 이미지를 로그인 없이
|
||||||
|
pull할 수 있습니다. 익명 push/delete는 허용되지 않습니다.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
<Table>
|
<Table>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
<TableRow v-for="item in aclPatterns" :key="item.pattern">
|
<TableRow v-for="item in aclPatterns" :key="item.pattern">
|
||||||
@@ -103,7 +111,7 @@ REGISTRY_PUBLIC_URL={{ registryHost }}</code></pre>
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Info } from '@lucide/vue'
|
import { Globe2, Info } from '@lucide/vue'
|
||||||
|
|
||||||
useHead({ title: '가이드' })
|
useHead({ title: '가이드' })
|
||||||
|
|
||||||
|
|||||||
+53
-13
@@ -33,18 +33,20 @@
|
|||||||
<TableCell class="pl-6">
|
<TableCell class="pl-6">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="flex size-8 items-center justify-center rounded-full bg-muted">
|
<span class="flex size-8 items-center justify-center rounded-full bg-muted">
|
||||||
<UserRound class="size-4" />
|
<Globe2 v-if="isEveryone(user)" class="size-4" />
|
||||||
|
<UserRound v-else class="size-4" />
|
||||||
</span>
|
</span>
|
||||||
<span class="font-mono font-medium">{{ user.username }}</span>
|
<span class="font-mono font-medium">{{ user.username }}</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge :variant="user.role === 'ADMIN' ? 'default' : 'secondary'">{{ user.role }}</Badge>
|
<Badge v-if="isEveryone(user)" variant="outline">PUBLIC</Badge>
|
||||||
|
<Badge v-else :variant="user.role === 'ADMIN' ? 'default' : 'secondary'">{{ user.role }}</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div class="flex items-center gap-2 text-sm">
|
<div class="flex items-center gap-2 text-sm">
|
||||||
<span class="size-2 rounded-full" :class="user.active ? 'bg-emerald-500' : 'bg-muted-foreground'" />
|
<span class="size-2 rounded-full" :class="user.active ? 'bg-emerald-500' : 'bg-muted-foreground'" />
|
||||||
{{ user.active ? '활성' : '비활성' }}
|
{{ user.active ? (isEveryone(user) ? '익명 pull 활성' : '활성') : '비활성' }}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -72,6 +74,7 @@
|
|||||||
<Pencil class="size-4" />
|
<Pencil class="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
v-if="!isEveryone(user)"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="text-destructive hover:text-destructive"
|
class="text-destructive hover:text-destructive"
|
||||||
@@ -91,20 +94,34 @@
|
|||||||
<DialogContent class="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
|
<DialogContent class="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
|
||||||
<form class="space-y-5" @submit.prevent="save">
|
<form class="space-y-5" @submit.prevent="save">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{{ form.id ? '사용자 편집' : '사용자 추가' }}</DialogTitle>
|
<DialogTitle>{{ form.system ? '공개 이미지 설정' : form.id ? '사용자 편집' : '사용자 추가' }}</DialogTitle>
|
||||||
<DialogDescription>이 계정은 관리 UI와 Docker CLI 로그인에 함께 사용됩니다.</DialogDescription>
|
<DialogDescription>
|
||||||
|
{{
|
||||||
|
form.system
|
||||||
|
? '선택한 저장소는 계정 없이 docker pull 할 수 있습니다.'
|
||||||
|
: '이 계정은 관리 UI와 Docker CLI 로그인에 함께 사용됩니다.'
|
||||||
|
}}
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Alert v-if="form.system">
|
||||||
|
<Globe2 class="size-4" />
|
||||||
|
<AlertTitle>everyone 시스템 사용자</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
로그인할 수 없으며 pull 권한만 부여됩니다. push와 delete는 항상 차단됩니다.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
<div class="grid gap-4 sm:grid-cols-2">
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="username">사용자명</Label>
|
<Label for="username">사용자명</Label>
|
||||||
<Input id="username" v-model="form.username" class="font-mono" :disabled="!!form.id" required />
|
<Input id="username" v-model="form.username" class="font-mono" :disabled="!!form.id" required />
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div v-if="!form.system" class="space-y-2">
|
||||||
<Label for="password">비밀번호 {{ form.id ? '(비워두면 유지)' : '' }}</Label>
|
<Label for="password">비밀번호 {{ form.id ? '(비워두면 유지)' : '' }}</Label>
|
||||||
<Input id="password" v-model="form.password" type="password" :required="!form.id" />
|
<Input id="password" v-model="form.password" type="password" :required="!form.id" />
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div v-if="!form.system" class="space-y-2">
|
||||||
<Label>역할</Label>
|
<Label>역할</Label>
|
||||||
<Select v-model="form.role">
|
<Select v-model="form.role">
|
||||||
<SelectTrigger class="w-full">
|
<SelectTrigger class="w-full">
|
||||||
@@ -119,7 +136,7 @@
|
|||||||
<div class="flex items-end pb-2">
|
<div class="flex items-end pb-2">
|
||||||
<label class="flex items-center gap-2 text-sm font-medium">
|
<label class="flex items-center gap-2 text-sm font-medium">
|
||||||
<Checkbox v-model="form.active" />
|
<Checkbox v-model="form.active" />
|
||||||
활성 계정
|
{{ form.system ? '익명 pull 활성' : '활성 계정' }}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -130,7 +147,9 @@
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<Label>저장소 ACL</Label>
|
<Label>저장소 ACL</Label>
|
||||||
<p class="mt-1 text-xs text-muted-foreground">저장소 패턴별 허용 작업을 선택하세요.</p>
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
{{ form.system ? '계정 없이 pull을 허용할 저장소 패턴입니다.' : '저장소 패턴별 허용 작업을 선택하세요.' }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" type="button" class="gap-2" @click="addAcl">
|
<Button variant="outline" size="sm" type="button" class="gap-2" @click="addAcl">
|
||||||
<Plus class="size-4" />
|
<Plus class="size-4" />
|
||||||
@@ -151,13 +170,14 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-5">
|
<div class="flex flex-wrap gap-5">
|
||||||
<label class="flex items-center gap-2 text-sm">
|
<label v-if="!form.system" class="flex items-center gap-2 text-sm">
|
||||||
<Checkbox v-model="acl.canPull" /> pull
|
<Checkbox v-model="acl.canPull" /> pull
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center gap-2 text-sm">
|
<Badge v-else variant="secondary">pull only</Badge>
|
||||||
|
<label v-if="!form.system" class="flex items-center gap-2 text-sm">
|
||||||
<Checkbox v-model="acl.canPush" /> push
|
<Checkbox v-model="acl.canPush" /> push
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center gap-2 text-sm">
|
<label v-if="!form.system" class="flex items-center gap-2 text-sm">
|
||||||
<Checkbox v-model="acl.canDelete" /> delete
|
<Checkbox v-model="acl.canDelete" /> delete
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -181,6 +201,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import {
|
||||||
CircleAlert,
|
CircleAlert,
|
||||||
|
Globe2,
|
||||||
LoaderCircle,
|
LoaderCircle,
|
||||||
Pencil,
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -192,6 +213,8 @@ import {
|
|||||||
|
|
||||||
useHead({ title: '사용자' })
|
useHead({ title: '사용자' })
|
||||||
|
|
||||||
|
const EVERYONE_USERNAME = 'everyone'
|
||||||
|
|
||||||
type AclForm = {
|
type AclForm = {
|
||||||
repository: string
|
repository: string
|
||||||
canPull: boolean
|
canPull: boolean
|
||||||
@@ -212,6 +235,7 @@ const editing = ref(false)
|
|||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
id: '',
|
id: '',
|
||||||
|
system: false,
|
||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
role: 'USER' as 'ADMIN' | 'USER',
|
role: 'USER' as 'ADMIN' | 'USER',
|
||||||
@@ -229,6 +253,7 @@ if (me.data.value?.user?.role !== 'ADMIN') {
|
|||||||
|
|
||||||
function startCreate() {
|
function startCreate() {
|
||||||
form.id = ''
|
form.id = ''
|
||||||
|
form.system = false
|
||||||
form.username = ''
|
form.username = ''
|
||||||
form.password = ''
|
form.password = ''
|
||||||
form.role = 'USER'
|
form.role = 'USER'
|
||||||
@@ -239,6 +264,7 @@ function startCreate() {
|
|||||||
|
|
||||||
function startEdit(user: UserRow) {
|
function startEdit(user: UserRow) {
|
||||||
form.id = user.id
|
form.id = user.id
|
||||||
|
form.system = isEveryone(user)
|
||||||
form.username = user.username
|
form.username = user.username
|
||||||
form.password = ''
|
form.password = ''
|
||||||
form.role = user.role
|
form.role = user.role
|
||||||
@@ -256,6 +282,10 @@ function addAcl() {
|
|||||||
form.acls.push({ repository: '', canPull: true, canPush: false, canDelete: false })
|
form.acls.push({ repository: '', canPull: true, canPush: false, canDelete: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isEveryone(user: Pick<UserRow, 'username'>) {
|
||||||
|
return user.username.toLowerCase() === EVERYONE_USERNAME
|
||||||
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
saving.value = true
|
saving.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
@@ -263,7 +293,17 @@ async function save() {
|
|||||||
if (form.id) {
|
if (form.id) {
|
||||||
await $fetch(`/api/users/${form.id}`, {
|
await $fetch(`/api/users/${form.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: {
|
body: form.system
|
||||||
|
? {
|
||||||
|
active: form.active,
|
||||||
|
acls: form.acls.map((acl) => ({
|
||||||
|
repository: acl.repository,
|
||||||
|
canPull: true,
|
||||||
|
canPush: false,
|
||||||
|
canDelete: false,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: {
|
||||||
...(form.password ? { password: form.password } : {}),
|
...(form.password ? { password: form.password } : {}),
|
||||||
role: form.role,
|
role: form.role,
|
||||||
active: form.active,
|
active: form.active,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { PrismaClient } from '../../generated/prisma/client'
|
import type { PrismaClient } from '../../generated/prisma/client'
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
import { hashPassword } from '../server/utils/password'
|
import { hashPassword } from '../server/utils/password'
|
||||||
|
import { EVERYONE_USERNAME } from '../server/utils/system-users'
|
||||||
|
|
||||||
type SeedPrisma = Pick<PrismaClient, 'user'>
|
type SeedPrisma = Pick<PrismaClient, 'user'>
|
||||||
|
|
||||||
@@ -7,6 +9,10 @@ export async function seedAdmin(prisma: SeedPrisma) {
|
|||||||
const username = process.env.ADMIN_USERNAME || 'admin'
|
const username = process.env.ADMIN_USERNAME || 'admin'
|
||||||
const password = process.env.ADMIN_PASSWORD || 'admin123'
|
const password = process.env.ADMIN_PASSWORD || 'admin123'
|
||||||
|
|
||||||
|
if (username.toLowerCase() === EVERYONE_USERNAME) {
|
||||||
|
throw new Error(`ADMIN_USERNAME cannot use reserved username "${EVERYONE_USERNAME}"`)
|
||||||
|
}
|
||||||
|
|
||||||
const existing = await prisma.user.findUnique({ where: { username } })
|
const existing = await prisma.user.findUnique({ where: { username } })
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return existing
|
return existing
|
||||||
@@ -21,3 +27,28 @@ export async function seedAdmin(prisma: SeedPrisma) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function seedEveryone(prisma: SeedPrisma) {
|
||||||
|
const existing = await prisma.user.findUnique({
|
||||||
|
where: { username: EVERYONE_USERNAME },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (existing.role !== 'USER') {
|
||||||
|
return prisma.user.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { role: 'USER' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.user.create({
|
||||||
|
data: {
|
||||||
|
username: EVERYONE_USERNAME,
|
||||||
|
passwordHash: await hashPassword(randomUUID()),
|
||||||
|
role: 'USER',
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -9,6 +9,10 @@ export default defineEventHandler(async (event) => {
|
|||||||
await ensureBootstrap()
|
await ensureBootstrap()
|
||||||
const body = bodySchema.parse(await readBody(event))
|
const body = bodySchema.parse(await readBody(event))
|
||||||
|
|
||||||
|
if (isEveryoneUsername(body.username)) {
|
||||||
|
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
|
||||||
|
}
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({ where: { username: body.username } })
|
const user = await prisma.user.findUnique({ where: { username: body.username } })
|
||||||
if (!user || !user.active) {
|
if (!user || !user.active) {
|
||||||
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
|
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ export default defineEventHandler(async (event) => {
|
|||||||
throw createError({ statusCode: 400, statusMessage: 'Cannot delete bootstrap admin' })
|
throw createError({ statusCode: 400, statusMessage: 'Cannot delete bootstrap admin' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isEveryoneUsername(existing.username)) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Cannot delete system user "everyone"' })
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.user.delete({ where: { id } })
|
await prisma.user.delete({ where: { id } })
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
})
|
})
|
||||||
@@ -29,6 +29,14 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = bodySchema.parse(await readBody(event))
|
const body = bodySchema.parse(await readBody(event))
|
||||||
|
const isEveryone = isEveryoneUsername(existing.username)
|
||||||
|
|
||||||
|
if (isEveryone && (body.password || body.role)) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Cannot change password or role for system user "everyone"',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (existing.username === (process.env.ADMIN_USERNAME || 'admin') && body.active === false) {
|
if (existing.username === (process.env.ADMIN_USERNAME || 'admin') && body.active === false) {
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Cannot deactivate bootstrap admin' })
|
throw createError({ statusCode: 400, statusMessage: 'Cannot deactivate bootstrap admin' })
|
||||||
@@ -42,12 +50,14 @@ export default defineEventHandler(async (event) => {
|
|||||||
if (body.acls) {
|
if (body.acls) {
|
||||||
await tx.acl.deleteMany({ where: { userId: id } })
|
await tx.acl.deleteMany({ where: { userId: id } })
|
||||||
await tx.acl.createMany({
|
await tx.acl.createMany({
|
||||||
data: body.acls.map((acl) => ({
|
data: body.acls
|
||||||
|
.filter((acl) => !isEveryone || acl.canPull)
|
||||||
|
.map((acl) => ({
|
||||||
userId: id,
|
userId: id,
|
||||||
repository: acl.repository,
|
repository: acl.repository,
|
||||||
canPull: acl.canPull,
|
canPull: isEveryone ? true : acl.canPull,
|
||||||
canPush: acl.canPush,
|
canPush: isEveryone ? false : acl.canPush,
|
||||||
canDelete: acl.canDelete,
|
canDelete: isEveryone ? false : acl.canDelete,
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ export default defineEventHandler(async (event) => {
|
|||||||
await ensureBootstrap()
|
await ensureBootstrap()
|
||||||
|
|
||||||
const body = bodySchema.parse(await readBody(event))
|
const body = bodySchema.parse(await readBody(event))
|
||||||
|
if (isEveryoneUsername(body.username)) {
|
||||||
|
throw createError({ statusCode: 409, statusMessage: 'Username "everyone" is reserved' })
|
||||||
|
}
|
||||||
|
|
||||||
const existing = await prisma.user.findUnique({ where: { username: body.username } })
|
const existing = await prisma.user.findUnique({ where: { username: body.username } })
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw createError({ statusCode: 409, statusMessage: 'Username already exists' })
|
throw createError({ statusCode: 409, statusMessage: 'Username already exists' })
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
let password: string | undefined
|
let password: string | undefined
|
||||||
let refreshTokenIn: string | undefined
|
let refreshTokenIn: string | undefined
|
||||||
|
|
||||||
const basic = parseBasicAuth(getHeader(event, 'authorization'))
|
const authorizationHeader = getHeader(event, 'authorization')
|
||||||
|
const basic = parseBasicAuth(authorizationHeader)
|
||||||
if (basic) {
|
if (basic) {
|
||||||
username = basic.username
|
username = basic.username
|
||||||
password = basic.password
|
password = basic.password
|
||||||
@@ -32,6 +33,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
let user =
|
let user =
|
||||||
null as null | Awaited<ReturnType<typeof prisma.user.findUnique<{ include: { acls: true } }>>>
|
null as null | Awaited<ReturnType<typeof prisma.user.findUnique<{ include: { acls: true } }>>>
|
||||||
|
let anonymous = false
|
||||||
|
|
||||||
if (refreshTokenIn) {
|
if (refreshTokenIn) {
|
||||||
const refreshUser = await verifyRefreshToken(refreshTokenIn)
|
const refreshUser = await verifyRefreshToken(refreshTokenIn)
|
||||||
@@ -42,9 +44,9 @@ export default defineEventHandler(async (event) => {
|
|||||||
where: { username: refreshUser },
|
where: { username: refreshUser },
|
||||||
include: { acls: true },
|
include: { acls: true },
|
||||||
})
|
})
|
||||||
} else {
|
} else if (username || password || authorizationHeader) {
|
||||||
if (!username || !password) {
|
if (!username || !password || isEveryoneUsername(username)) {
|
||||||
throw createError({ statusCode: 401, statusMessage: 'Authentication required' })
|
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
|
||||||
}
|
}
|
||||||
user = await prisma.user.findUnique({
|
user = await prisma.user.findUnique({
|
||||||
where: { username },
|
where: { username },
|
||||||
@@ -57,19 +59,31 @@ export default defineEventHandler(async (event) => {
|
|||||||
if (!ok) {
|
if (!ok) {
|
||||||
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
|
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
anonymous = true
|
||||||
|
user = await prisma.user.findUnique({
|
||||||
|
where: { username: EVERYONE_USERNAME },
|
||||||
|
include: { acls: true },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user || !user.active) {
|
if (!user || !user.active) {
|
||||||
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
|
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (account && account !== user.username) {
|
if (!anonymous && isEveryoneUsername(user.username)) {
|
||||||
|
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!anonymous && account && account !== user.username) {
|
||||||
throw createError({ statusCode: 401, statusMessage: 'Account mismatch' })
|
throw createError({ statusCode: 401, statusMessage: 'Account mismatch' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const access = authorizeScopes(user, scopes)
|
const access = anonymous
|
||||||
|
? authorizeAnonymousScopes(user, scopes)
|
||||||
|
: authorizeScopes(user, scopes)
|
||||||
const { token, expiresIn, issuedAt } = await signRegistryToken({
|
const { token, expiresIn, issuedAt } = await signRegistryToken({
|
||||||
subject: user.username,
|
subject: anonymous ? 'anonymous' : user.username,
|
||||||
access,
|
access,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -80,7 +94,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
issued_at: issuedAt,
|
issued_at: issuedAt,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (offline || refreshTokenIn) {
|
if (!anonymous && (offline || refreshTokenIn)) {
|
||||||
response.refresh_token = await createRefreshToken(user.username)
|
response.refresh_token = await createRefreshToken(user.username)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,29 @@ export function authorizeScopes(user: UserWithAcls, scopes: ParsedScope[]): Regi
|
|||||||
return access
|
return access
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Anonymous clients may only receive pull access explicitly granted to everyone. */
|
||||||
|
export function authorizeAnonymousScopes(
|
||||||
|
everyone: UserWithAcls,
|
||||||
|
scopes: ParsedScope[],
|
||||||
|
): RegistryAccess[] {
|
||||||
|
if (!everyone.active) return []
|
||||||
|
|
||||||
|
const access: RegistryAccess[] = []
|
||||||
|
for (const scope of scopes) {
|
||||||
|
if (scope.type !== 'repository' || !scope.actions.includes('pull')) continue
|
||||||
|
|
||||||
|
const allowed = allowedActionsForRepo(everyone, scope.name)
|
||||||
|
if (!allowed.includes('pull') && !allowed.includes('*')) continue
|
||||||
|
|
||||||
|
access.push({
|
||||||
|
type: 'repository',
|
||||||
|
name: scope.name,
|
||||||
|
actions: ['pull'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return access
|
||||||
|
}
|
||||||
|
|
||||||
export function parseBasicAuth(header: string | undefined): { username: string; password: string } | null {
|
export function parseBasicAuth(header: string | undefined): { username: string; password: string } | null {
|
||||||
if (!header?.startsWith('Basic ')) return null
|
if (!header?.startsWith('Basic ')) return null
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ensureTokenCerts } from './certs'
|
import { ensureTokenCerts } from './certs'
|
||||||
import { prisma } from './prisma'
|
import { prisma } from './prisma'
|
||||||
import { seedAdmin } from '../../prisma/seed'
|
import { seedAdmin, seedEveryone } from '../../prisma/seed'
|
||||||
|
|
||||||
let ready: Promise<void> | null = null
|
let ready: Promise<void> | null = null
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ async function bootstrap() {
|
|||||||
ensureTokenCerts()
|
ensureTokenCerts()
|
||||||
await prisma.$connect()
|
await prisma.$connect()
|
||||||
await seedAdmin(prisma)
|
await seedAdmin(prisma)
|
||||||
|
await seedEveryone(prisma)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureBootstrap() {
|
export async function ensureBootstrap() {
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export const EVERYONE_USERNAME = 'everyone'
|
||||||
|
|
||||||
|
export function isEveryoneUsername(username: string): boolean {
|
||||||
|
return username.toLowerCase() === EVERYONE_USERNAME
|
||||||
|
}
|
||||||
Reference in new issue
Block a user