feat(auth): add OIDC-only member authentication (#686)

* feat(auth): add OIDC member authentication

* docs(auth): document OIDC member login

* fix(auth): address OAuth review feedback

* refactor(auth): move OAuth flow into member provider
This commit is contained in:
@刘羡鱼
2026-09-24 06:15:46 +08:00
committed by GitHub
parent ddf2787c5e
commit dae23a90b5
28 changed files with 1517 additions and 61 deletions
+31 -2
View File
@@ -12,7 +12,8 @@
backgroundColor: Background(seed),
}"
>
{{ seed.substring(0, 2).toUpperCase() }}
<img v-if="imageURL" :src="imageURL" :alt="seed" @error="imageFailed = true" />
<template v-else>{{ seed.substring(0, 2).toUpperCase() }}</template>
</div>
</template>
@@ -25,19 +26,47 @@
display: inline-block;
overflow: hidden;
border-radius: 50%;
img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
}
</style>
<script lang="ts">
import { Vue, Component, Prop } from 'vue-property-decorator'
import { Vue, Component, Prop, Watch } from 'vue-property-decorator'
@Component({
name: 'neko-avatar',
})
export default class extends Vue {
@Prop(String) readonly seed: string | undefined
@Prop(String) readonly avatar: string | undefined
@Prop(Number) readonly size: number | undefined
private imageFailed: boolean = false
get imageURL(): string {
if (this.imageFailed || !this.avatar) {
return ''
}
try {
const avatar = new URL(this.avatar)
return avatar.protocol === 'http:' || avatar.protocol === 'https:' ? avatar.toString() : ''
} catch (_) {
return ''
}
}
@Watch('avatar')
onAvatarChanged() {
this.imageFailed = false
}
Background(seed: string) {
let a = 0,
b = 0,
+6 -1
View File
@@ -11,7 +11,12 @@
}"
>
<div class="author" @contextmenu.stop.prevent="onContext($event, { member: member(message.id) })">
<neko-avatar class="avatar" :seed="member(message.id).displayname" :size="40" />
<neko-avatar
class="avatar"
:seed="member(message.id).displayname"
:avatar="member(message.id).avatar"
:size="40"
/>
</div>
<div class="content">
<div class="content-head">
+66 -4
View File
@@ -8,11 +8,30 @@
<form class="message" v-if="!connecting" @submit.stop.prevent="connect">
<span v-if="!autoPassword">{{ $t('connect.login_title') }}</span>
<span v-else>{{ $t('connect.invitation_title') }}</span>
<input type="text" :placeholder="$t('connect.displayname')" v-model="displayname" autofocus />
<input type="password" :placeholder="$t('connect.password')" v-model="password" v-if="!autoPassword" />
<button type="submit" @click.stop.prevent="login">
<input
v-if="passwordLoginEnabled"
type="text"
:placeholder="$t('connect.displayname')"
v-model="displayname"
autofocus
/>
<input
v-if="passwordLoginEnabled && !autoPassword"
type="password"
:placeholder="$t('connect.password')"
v-model="password"
/>
<button v-if="passwordLoginEnabled" type="submit" @click.stop.prevent="login">
{{ $t('connect.connect') }}
</button>
<button
v-if="oauthEnabled && !autoPassword"
type="button"
class="oauth-login"
@click.stop.prevent="loginWithOAuth"
>
{{ oauthName }} Login
</button>
</form>
<div class="loader" v-if="connecting">
<div class="bounce1"></div>
@@ -101,6 +120,11 @@
line-height: 30px;
margin: 5px 0;
border: none;
&.oauth-login {
background: $background-tertiary;
border: 1px solid $style-primary;
}
}
}
@@ -155,8 +179,16 @@
private displayname: string = ''
private password: string = ''
private oauthEnabled: boolean = false
private oauthName: string = 'OAuth'
private oauthLoginURL: string = ''
private passwordLoginEnabled: boolean = true
async mounted() {
if (await this.loadOAuthConfig()) {
return
}
mounted() {
// auto-password fill
let password = this.$accessor.password
if (this.autoPassword !== null) {
@@ -182,6 +214,36 @@
return this.$accessor.connecting
}
async loadOAuthConfig(): Promise<boolean> {
try {
const response = await this.$http.get('api/oauth/config')
const config = response.data
this.oauthEnabled = config.enabled === true
this.oauthName = config.name || 'OAuth'
this.oauthLoginURL = config.login_url || ''
this.passwordLoginEnabled = config.password_login_enabled !== false
if (this.oauthEnabled && !this.passwordLoginEnabled) {
try {
await this.$http.get('api/whoami')
this.$accessor.login({ displayname: '', password: '' })
} catch (_) {
// No authenticated session yet; leave the OAuth button visible.
}
return true
}
} catch (_) {
// An unauthenticated OAuth user remains on the OAuth login screen.
}
return false
}
loginWithOAuth() {
if (this.oauthLoginURL !== '') {
window.location.assign(this.oauthLoginURL)
}
}
removeUrlParam(param: string) {
let url = document.location.href
let urlparts = url.split('?')
+6 -1
View File
@@ -3,7 +3,12 @@
<template slot-scope="child" v-if="child.data">
<li class="header">
<div class="user">
<neko-avatar class="avatar" :seed="child.data.member.displayname" :size="25" />
<neko-avatar
class="avatar"
:seed="child.data.member.displayname"
:avatar="child.data.member.avatar"
:size="25"
/>
<strong>{{ child.data.member.displayname }}</strong>
</div>
</li>
+2 -2
View File
@@ -4,7 +4,7 @@
<ul class="members-list">
<li v-if="member">
<div :class="[{ host: member.id === host }, 'self', 'member']">
<neko-avatar class="avatar" :seed="member.displayname" :size="50" />
<neko-avatar class="avatar" :seed="member.displayname" :avatar="member.avatar" :size="50" />
</div>
</li>
<template v-for="(member, index) in members">
@@ -17,7 +17,7 @@
:class="[{ host: member.id === host, admin: member.admin }, 'member']"
@contextmenu.stop.prevent="onContext($event, { member })"
>
<neko-avatar class="avatar" :seed="member.displayname" :size="50" />
<neko-avatar class="avatar" :seed="member.displayname" :avatar="member.avatar" :size="50" />
</div>
</li>
</template>
+1
View File
@@ -1,6 +1,7 @@
export interface Member {
id: string
displayname: string
avatar?: string
admin: boolean
muted: boolean
connected?: boolean