mirror of
https://github.com/HangarMC/Hangar.git
synced 2024-11-27 06:01:08 +08:00
try to integrate new auth
Signed-off-by: MiniDigger <admin@benndorf.dev>
This commit is contained in:
parent
051e6c281e
commit
733c151f5f
@ -33,8 +33,7 @@ services:
|
||||
context: ../..
|
||||
dockerfile: docker/deployment/hangar-backend/Dockerfile
|
||||
environment:
|
||||
SSO_SECRET: "${SSO_SECRET}"
|
||||
API_KEY: "${API_KEY}"
|
||||
SSO_CLIENT_ID: "${SSO_CLIENT_ID}"
|
||||
POSTGRES_USER: "${POSTGRES_USER}"
|
||||
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
|
||||
TOKEN_SECRET: "${TOKEN_SECRET}"
|
||||
|
@ -3,35 +3,24 @@ spring:
|
||||
url: jdbc:postgresql://hangar_new_db:5432/hangar
|
||||
username: "${POSTGRES_USER}"
|
||||
password: "${POSTGRES_PASSWORD}"
|
||||
jackson:
|
||||
serialization:
|
||||
WRITE_DATES_AS_TIMESTAMPS: false
|
||||
date-format: com.fasterxml.jackson.databind.util.StdDateFormat
|
||||
|
||||
fake-user:
|
||||
enabled: false
|
||||
|
||||
hangar:
|
||||
dev: false
|
||||
auth-url: "https://hangar-auth.benndorf.dev"
|
||||
base-url: "https://hangar.benndorf.dev"
|
||||
plugin-upload-dir: "/hangar/uploads"
|
||||
|
||||
licenses:
|
||||
- "MIT"
|
||||
- "Apache 2.0"
|
||||
- "GPL"
|
||||
- "LPGL"
|
||||
- "(custom)"
|
||||
|
||||
announcements:
|
||||
-
|
||||
text: "This is a staging server for testing purposes. Data could be deleted at any time. email confirmations are disabled. If you wanna help test, sneak into #hangar-dev"
|
||||
color: "#ff544b"
|
||||
|
||||
sso:
|
||||
secret: "${SSO_SECRET}"
|
||||
api-key: "${API_KEY}"
|
||||
auth-url: "https://hangar-auth.benndorf.dev"
|
||||
oauth-url: "https://hangar-oauth.benndorf.dev"
|
||||
client-id: "${SSO_CLIENT_ID}"
|
||||
|
||||
security:
|
||||
api:
|
||||
@ -39,5 +28,7 @@ hangar:
|
||||
avatar-url: "https://hangar-auth.benndorf.dev/avatar/%s?size=120x120"
|
||||
token-secret: "${TOKEN_SECRET}"
|
||||
|
||||
projects:
|
||||
name-regex: "^[a-zA-Z0-9-_]{3,}$"
|
||||
logging:
|
||||
level:
|
||||
root: INFO
|
||||
org.springframework: INFO
|
||||
|
@ -10,7 +10,13 @@ services:
|
||||
POSTGRES_PASSWORD: 'hangar'
|
||||
ports:
|
||||
- "5432:5432"
|
||||
networks:
|
||||
- web
|
||||
volumes:
|
||||
- db_data:/var/lib/postgresql/data
|
||||
volumes:
|
||||
db_data:
|
||||
networks:
|
||||
web:
|
||||
name: traefik-overlay
|
||||
external: true
|
||||
|
@ -15,19 +15,19 @@ services:
|
||||
working_dir: /app
|
||||
depends_on:
|
||||
- 'db'
|
||||
- 'auth'
|
||||
# - 'auth'
|
||||
- 'mail'
|
||||
stdin_open: true
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.services.hangarnew.loadbalancer.server.port=8080"
|
||||
- "traefik.http.routers.hangarnew.rule=Host(`hangar-new.minidigger.me`)"
|
||||
- "traefik.http.routers.hangarnew.rule=Host(`hangar.benndorf.dev`)"
|
||||
- "traefik.http.routers.hangarnew.entrypoints=web-secure"
|
||||
- "traefik.http.routers.hangarnew.tls=true"
|
||||
- "traefik.http.routers.hangarnew.tls.options=default"
|
||||
- "traefik.http.routers.hangarnew.tls.certresolver=default"
|
||||
- "traefik.http.routers.hangarnew.tls.domains[0].main=minidigger.me"
|
||||
- "traefik.http.routers.hangarnew.tls.domains[0].sans=*.minidigger.me"
|
||||
- "traefik.http.routers.hangarnew.tls.domains[0].main=benndorf.dev"
|
||||
- "traefik.http.routers.hangarnew.tls.domains[0].sans=*.benndorf.dev"
|
||||
networks:
|
||||
- web
|
||||
user: appuser
|
||||
@ -54,63 +54,18 @@ services:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.services.hangar-mail.loadbalancer.server.port=8025"
|
||||
- "traefik.http.routers.hangar-mail.middlewares=basicauth@file"
|
||||
- "traefik.http.routers.hangar-mail.rule=Host(`hangar-mail-new.minidigger.me`)"
|
||||
- "traefik.http.routers.hangar-mail.rule=Host(`hangar-mail.benndorf.dev`)"
|
||||
- "traefik.http.routers.hangar-mail.entrypoints=web-secure"
|
||||
- "traefik.http.routers.hangar-mail.tls=true"
|
||||
- "traefik.http.routers.hangar-mail.tls.options=default"
|
||||
- "traefik.http.routers.hangar-mail.tls.certresolver=default"
|
||||
- "traefik.http.routers.hangar-mail.tls.domains[0].main=minidigger.me"
|
||||
- "traefik.http.routers.hangar-mail.tls.domains[0].sans=*.minidigger.me"
|
||||
- "traefik.http.routers.hangar-mail.tls.domains[0].main=benndorf.dev"
|
||||
- "traefik.http.routers.hangar-mail.tls.domains[0].sans=*.benndorf.dev"
|
||||
networks:
|
||||
- web
|
||||
ports:
|
||||
- "8025:8025"
|
||||
auth:
|
||||
build:
|
||||
context: ../../HangarAuth
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
- "db"
|
||||
- "redis"
|
||||
- "mail"
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.services.hangar-auth.loadbalancer.server.port=8000"
|
||||
- "traefik.http.routers.hangar-auth.rule=Host(`hangar-auth-new.minidigger.me`)"
|
||||
- "traefik.http.routers.hangar-auth.entrypoints=web-secure"
|
||||
- "traefik.http.routers.hangar-auth.tls=true"
|
||||
- "traefik.http.routers.hangar-auth.tls.options=default"
|
||||
- "traefik.http.routers.hangar-auth.tls.certresolver=default"
|
||||
- "traefik.http.routers.hangar-auth.tls.domains[0].main=minidigger.me"
|
||||
- "traefik.http.routers.hangar-auth.tls.domains[0].sans=*.minidigger.me"
|
||||
volumes:
|
||||
- public_html:/public_html
|
||||
environment:
|
||||
PYTHONUNBUFFERED: 1
|
||||
SECRET_KEY: "TzNc3RTpfVn1xxNV90PPGEfs7SZhy5"
|
||||
EMAIL_HOST: "mail"
|
||||
EMAIL_PORT: "1025"
|
||||
EMAIL_SSL: "false"
|
||||
EMAIL_TLS: "false"
|
||||
EMAIL_HOST_USER: "dum"
|
||||
EMAIL_HOST_PASSWORD: "dum"
|
||||
HANGAR_HOST: "http://localhost:3000"
|
||||
DB_NAME: "hangarauth"
|
||||
DB_USER: "hangar"
|
||||
DB_PASSWORD: "hangar"
|
||||
DB_HOST: "db"
|
||||
REDIS_HOST: "redis"
|
||||
SSO_ENDPOINT_hangar: "{ 'sync_sso_endpoint': ('http://app:8080/api/sync_sso'), 'sso_secret': 'changeme', 'api_key': 'changeme' }"
|
||||
DEBUG: "true"
|
||||
DJANGO_SETTINGS_MODULE: "spongeauth.settings.prod"
|
||||
networks:
|
||||
- web
|
||||
redis:
|
||||
image: redis:6.2.1
|
||||
networks:
|
||||
- web
|
||||
- "1025:1025"
|
||||
|
||||
networks:
|
||||
web:
|
||||
|
@ -1,151 +1,27 @@
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
spring:
|
||||
freemarker:
|
||||
request-context-attribute: rc
|
||||
sql:
|
||||
init:
|
||||
platform: postgres
|
||||
datasource:
|
||||
url: jdbc:postgresql://db:5432/hangar
|
||||
username: hangar
|
||||
password: hangar
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 10MB
|
||||
max-request-size: 11MB
|
||||
devtools:
|
||||
restart:
|
||||
additional-exclude: work/**
|
||||
jackson:
|
||||
serialization:
|
||||
WRITE_DATES_AS_TIMESTAMPS: false
|
||||
date-format: com.fasterxml.jackson.databind.util.StdDateFormat
|
||||
|
||||
logging:
|
||||
level:
|
||||
# root: TRACE
|
||||
io:
|
||||
papermc:
|
||||
hangar:
|
||||
config:
|
||||
jackson: DEBUG
|
||||
|
||||
fake-user:
|
||||
enabled: false
|
||||
id: -2
|
||||
name: paper
|
||||
username: paper
|
||||
email: paper@papermc.io
|
||||
|
||||
hangar:
|
||||
dev: true
|
||||
auth-url: "http://localhost:8000"
|
||||
base-url: "http://localhost:3000"
|
||||
plugin-upload-dir: "/uploads"
|
||||
ga-code: "UA-38006759-9"
|
||||
|
||||
licenses:
|
||||
- "MIT"
|
||||
- "Apache 2.0"
|
||||
- "GPL"
|
||||
- "LPGL"
|
||||
- "(custom)"
|
||||
|
||||
sponsors:
|
||||
- name: Beer
|
||||
image: /images/sponsors/beer.jpg
|
||||
link: https://minidigger.me
|
||||
- name: MiniDigger
|
||||
image: /images/sponsors/minidigger.png
|
||||
link: https://minidigger.me
|
||||
|
||||
homepage:
|
||||
update-interval: 10m
|
||||
|
||||
channels:
|
||||
max-name-len: 15
|
||||
name-regex: "^[a-zA-Z0-9]+$"
|
||||
color-default: cyan
|
||||
name-default: "Release"
|
||||
|
||||
pages:
|
||||
home:
|
||||
name: "Home"
|
||||
message: "Welcome to your new project!"
|
||||
min-len: 15
|
||||
max-len: 75000
|
||||
name-regex: "^[a-zA-Z0-9-_ ]+$"
|
||||
max-name-len: 25
|
||||
min-name-len: 3
|
||||
|
||||
projects:
|
||||
max-name-len: 25
|
||||
max-pages: 50
|
||||
max-channels: 5
|
||||
init-load: 25
|
||||
init-version-load: 10
|
||||
max-desc-len: 120
|
||||
file-validate: true
|
||||
stale-age: 28d
|
||||
check-interval: 1h
|
||||
draft-expire: 1d
|
||||
user-grid-page-size: 30
|
||||
max-keywords: 5
|
||||
unsafe-download-max-age: 10
|
||||
name-regex: "^[a-zA-Z0-9-_]{3,}$"
|
||||
|
||||
orgs:
|
||||
create-limit: 5
|
||||
dummy-email-domain: "org.papermc.io"
|
||||
enabled: true
|
||||
|
||||
users:
|
||||
max-tagline-len: 100
|
||||
author-page-size: 25
|
||||
staff-roles:
|
||||
- Hangar_Admin
|
||||
- Hangar_Mod
|
||||
- Hangar_Dev
|
||||
|
||||
|
||||
sso:
|
||||
enabled: true
|
||||
# relative to auth-url
|
||||
login-url: "/sso/"
|
||||
signup-url: "/sso/signup/"
|
||||
verify-url: "/sso/sudo/"
|
||||
logout-url: "/accounts/logout/"
|
||||
avatar-url: "/avatar/%s?size=120x120"
|
||||
secret: "changeme"
|
||||
api-key: "changeme"
|
||||
timeout: "2s"
|
||||
reset: "10m"
|
||||
|
||||
security:
|
||||
secure: false
|
||||
unsafe-download-max-age: 600000
|
||||
token-issuer: "Hangar"
|
||||
token-secret: "secret!"
|
||||
token-expiry: 300 # seconds
|
||||
refresh-token-expiry: 30 # days
|
||||
api:
|
||||
url: "http://auth:8000"
|
||||
avatar-url: "http://localhost:8000/avatar/%s?size=120x120" # only comment in if you run auth locally
|
||||
timeout: 10000
|
||||
safe-download-hosts:
|
||||
- "github.com"
|
||||
|
||||
discourse:
|
||||
enabled: false
|
||||
url: "http://localhost:80/"
|
||||
admin-user: "admin"
|
||||
api-key: "4657cc5e3096e505903b59eb789005eb3f207d0c61f62212bf929268740c1585"
|
||||
|
||||
jobs:
|
||||
check-interval: 30s
|
||||
unknown-error-timeout: 15
|
||||
status-error-timeout: 5
|
||||
not-available-timeout: 2
|
||||
max-concurrent-jobs: 32
|
||||
|
||||
logging:
|
||||
level:
|
||||
# root: TRACE
|
||||
io:
|
||||
papermc:
|
||||
hangar:
|
||||
config:
|
||||
jackson: DEBUG
|
||||
|
@ -14,7 +14,7 @@
|
||||
:loading="loadings.name"
|
||||
:error-messages="errorMsgs.name"
|
||||
:rules="[
|
||||
$util.$vc.require($t('channel.modal.name')),
|
||||
$util.$vc.required($t('channel.modal.name')),
|
||||
$util.$vc.regex($t('channel.modal.name'), validations.project.channels.regex),
|
||||
$util.$vc.maxLength(validations.project.channels.max),
|
||||
]"
|
||||
@ -50,7 +50,7 @@
|
||||
:label="$t('channel.modal.color')"
|
||||
:error-messages="errorMsgs.color"
|
||||
:loading="loadings.color"
|
||||
:rules="[$util.$vc.require($t('channel.modal.color'))]"
|
||||
:rules="[$util.$vc.required($t('channel.modal.color'))]"
|
||||
readonly
|
||||
/>
|
||||
<v-checkbox v-model="form.nonReviewed" :label="$t('channel.modal.reviewQueue')" />
|
||||
|
@ -7,7 +7,7 @@
|
||||
v-if="comment"
|
||||
v-model.trim="commentText"
|
||||
:label="$t('general.comment')"
|
||||
:rules="[$util.$vc.require()]"
|
||||
:rules="[$util.$vc.required()]"
|
||||
hide-details
|
||||
dense
|
||||
:rows="2"
|
||||
|
@ -12,7 +12,7 @@
|
||||
autofocus
|
||||
filled
|
||||
:label="label"
|
||||
:rules="[$util.$vc.require(label)]"
|
||||
:rules="[$util.$vc.required(label)]"
|
||||
:rows="2"
|
||||
auto-grow
|
||||
@keydown.enter.prevent=""
|
||||
|
@ -18,7 +18,7 @@
|
||||
hide-details
|
||||
auto-grow
|
||||
rows="2"
|
||||
:rules="[$util.$vc.require()]"
|
||||
:rules="[$util.$vc.required()]"
|
||||
:label="$t('visibility.modal.reason')"
|
||||
/>
|
||||
</v-form>
|
||||
|
@ -14,7 +14,7 @@
|
||||
filled
|
||||
:loading="validateLoading"
|
||||
:rules="[
|
||||
$util.$vc.require($t('page.new.name')),
|
||||
$util.$vc.required($t('page.new.name')),
|
||||
$util.$vc.regex($t('page.new.name'), validations.project.pageName.regex),
|
||||
$util.$vc.maxLength(validations.project.pageName.max),
|
||||
$util.$vc.minLength(validations.project.pageName.min),
|
||||
|
@ -14,10 +14,10 @@
|
||||
<span>{{ $t('project.flag.flagSent') }}</span>
|
||||
</v-tooltip>
|
||||
</template>
|
||||
<v-radio-group v-model="form.selection" :rules="[$util.$vc.require('A reason')]">
|
||||
<v-radio-group v-model="form.selection" :rules="[$util.$vc.required('A reason')]">
|
||||
<v-radio v-for="(reason, index) in flagReasons" :key="index" :label="$t(reason.title)" :value="reason.type" />
|
||||
</v-radio-group>
|
||||
<v-textarea v-model.trim="form.comment" rows="3" filled :rules="[$util.$vc.require('A comment')]" :label="$t('general.comment')" />
|
||||
<v-textarea v-model.trim="form.comment" rows="3" filled :rules="[$util.$vc.required('A comment')]" :label="$t('general.comment')" />
|
||||
</HangarModal>
|
||||
</template>
|
||||
|
||||
|
@ -24,7 +24,9 @@
|
||||
:placeholder="$t('version.new.form.externalUrl')"
|
||||
:disabled="dep.namespace !== null && Object.keys(dep.namespace).length !== 0"
|
||||
:rules="
|
||||
dep.namespace !== null && Object.keys(dep.namespace).length !== 0 ? [] : [$util.$vc.require('version.new.form.externalUrl')]
|
||||
dep.namespace !== null && Object.keys(dep.namespace).length !== 0
|
||||
? []
|
||||
: [$util.$vc.required('version.new.form.externalUrl')]
|
||||
"
|
||||
clearable
|
||||
/>
|
||||
@ -42,7 +44,7 @@
|
||||
clearable
|
||||
auto-select-first
|
||||
:disabled="!!dep.externalUrl"
|
||||
:rules="!!dep.externalUrl ? [] : [$util.$vc.require('version.new.form.hangarProject')]"
|
||||
:rules="!!dep.externalUrl ? [] : [$util.$vc.required('version.new.form.hangarProject')]"
|
||||
@update:search-input="onSearch($event, dep.name)"
|
||||
/>
|
||||
</td>
|
||||
@ -63,7 +65,7 @@
|
||||
hide-details
|
||||
flat
|
||||
:label="$t('general.name')"
|
||||
:rules="[$util.$vc.require($t('general.name'))]"
|
||||
:rules="[$util.$vc.required($t('general.name'))]"
|
||||
:disabled="noEditing"
|
||||
/>
|
||||
</td>
|
||||
@ -78,7 +80,7 @@
|
||||
:rules="
|
||||
newDep.namespace !== null && Object.keys(newDep.namespace).length !== 0
|
||||
? []
|
||||
: [$util.$vc.require('version.new.form.externalUrl')]
|
||||
: [$util.$vc.required('version.new.form.externalUrl')]
|
||||
"
|
||||
clearable
|
||||
/>
|
||||
@ -96,7 +98,7 @@
|
||||
clearable
|
||||
auto-select-first
|
||||
:disabled="!!newDep.externalUrl"
|
||||
:rules="!!newDep.externalUrl ? [] : [$util.$vc.require('version.new.form.hangarProject')]"
|
||||
:rules="!!newDep.externalUrl ? [] : [$util.$vc.required('version.new.form.hangarProject')]"
|
||||
@update:search-input="onNewDepSearch($event, index)"
|
||||
/>
|
||||
</td>
|
||||
|
@ -1,22 +0,0 @@
|
||||
import { Context } from '@nuxt/types';
|
||||
|
||||
export default ({ app: { $cookies }, $auth, redirect }: Context) => {
|
||||
let shouldRefresh = $cookies.get('HangarAuth_REFRESH', { parseJSON: false });
|
||||
if ($cookies.get('returnRoute')) {
|
||||
// is returning from login
|
||||
const returnRoute = $cookies.get<string>('returnRoute');
|
||||
$cookies.remove('returnRoute', {
|
||||
path: '/',
|
||||
});
|
||||
$cookies.remove('url', {
|
||||
path: '/',
|
||||
});
|
||||
redirect(returnRoute);
|
||||
// only refresh when fake user is enabled
|
||||
shouldRefresh = process.env.fakeUser || false;
|
||||
}
|
||||
|
||||
if (shouldRefresh) {
|
||||
return $auth.refreshUser();
|
||||
}
|
||||
};
|
@ -2,7 +2,10 @@ import { Context } from '@nuxt/types';
|
||||
import { UserPermissions } from 'hangar-api';
|
||||
import { AuthState } from '~/store/auth';
|
||||
|
||||
export default ({ store, params, $api, $auth }: Context) => {
|
||||
export default async ({ store, params, $api, $auth, $cookies }: Context) => {
|
||||
if ($cookies.get('HangarAuth_REFRESH', { parseJSON: false })) {
|
||||
await $auth.refreshUser();
|
||||
}
|
||||
if (params.author && params.slug) {
|
||||
if ($auth.isLoggedIn()) {
|
||||
return $api
|
||||
|
@ -16,6 +16,7 @@ require('events').EventEmitter.defaultMaxListeners = 20;
|
||||
require('dotenv').config();
|
||||
|
||||
const proxyHost = process.env.proxyHost || 'http://localhost:8080';
|
||||
const oauthHost = process.env.oauthHost || 'http://localhost:4444';
|
||||
const authHost = process.env.authHost || 'http://localhost:8000';
|
||||
const lazyAuthHost = process.env.lazyAuthHost || 'http://localhost:8000';
|
||||
const publicHost = process.env.PUBLIC_HOST || 'http://localhost:3000';
|
||||
@ -55,6 +56,7 @@ export default {
|
||||
|
||||
env: {
|
||||
proxyHost,
|
||||
oauthHost,
|
||||
authHost,
|
||||
lazyAuthHost,
|
||||
publicHost,
|
||||
@ -111,7 +113,7 @@ export default {
|
||||
},
|
||||
|
||||
router: {
|
||||
middleware: ['auth', 'routePermissions'],
|
||||
middleware: ['routePermissions'],
|
||||
},
|
||||
|
||||
proxy: [
|
||||
|
@ -38,6 +38,7 @@
|
||||
"nuxt-i18n": "Machine-Maker/i18n-module#fix/mixing-mem-leak",
|
||||
"nuxt-property-decorator": "2.9.1",
|
||||
"swagger-ui": "3.51.0",
|
||||
"uuid": "8.3.2",
|
||||
"vue": "2.6.14",
|
||||
"vue-server-renderer": "2.6.14",
|
||||
"vue-template-compiler": "2.6.14",
|
||||
@ -55,6 +56,7 @@
|
||||
"@types/lodash-es": "4.17.4",
|
||||
"@types/lru-cache": "5.1.1",
|
||||
"@types/swagger-ui-dist": "3.30.0",
|
||||
"@types/uuid": "8.3.1",
|
||||
"eslint": "7.29.0",
|
||||
"eslint-config-prettier": "8.3.0",
|
||||
"eslint-plugin-nuxt": "2.0.0",
|
||||
@ -63,7 +65,6 @@
|
||||
"husky": "6.0.0",
|
||||
"lint-staged": "11.0.0",
|
||||
"prettier": "2.3.2",
|
||||
"sass": "1.32.13",
|
||||
"sass-loader": "10.1.1",
|
||||
"typescript": "4.2.4",
|
||||
"vuetify": "2.5.5",
|
||||
|
@ -52,7 +52,7 @@
|
||||
filled
|
||||
item-text="title"
|
||||
item-value="apiName"
|
||||
:rules="[$util.$vc.require()]"
|
||||
:rules="[$util.$vc.required()]"
|
||||
/>
|
||||
</div>
|
||||
<v-divider />
|
||||
|
@ -122,7 +122,7 @@
|
||||
filled
|
||||
:label="$t('reviews.reviewMessage')"
|
||||
:rows="3"
|
||||
:rules="[$util.$vc.require($t('general.message'))]"
|
||||
:rules="[$util.$vc.required($t('general.message'))]"
|
||||
@keydown.enter.prevent=""
|
||||
/>
|
||||
<v-btn block color="primary" :loading="loadingValues.send" class="mt-2" :disabled="!validForm" @click="sendMessage">
|
||||
|
@ -32,7 +32,7 @@
|
||||
:autofocus="!isFile"
|
||||
filled
|
||||
:rules="[
|
||||
$util.$vc.require($t('version.new.form.versionString')),
|
||||
$util.$vc.required($t('version.new.form.versionString')),
|
||||
$util.$vc.regex($t('version.new.form.versionString'), validations.version.regex),
|
||||
]"
|
||||
/>
|
||||
@ -54,7 +54,7 @@
|
||||
v-model="pendingVersion.externalUrl"
|
||||
:label="$t('version.new.form.externalUrl')"
|
||||
filled
|
||||
:rules="[$util.$vc.require($t('version.new.form.externalUrl')), $util.$vc.url]"
|
||||
:rules="[$util.$vc.required($t('version.new.form.externalUrl')), $util.$vc.url]"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
@ -163,7 +163,7 @@
|
||||
:saveable="false"
|
||||
editing
|
||||
:raw="pendingVersion.description"
|
||||
:rules="[$util.$vc.require($t('version.new.form.release.bulletin'))]"
|
||||
:rules="[$util.$vc.required($t('version.new.form.release.bulletin'))]"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
@ -52,7 +52,7 @@
|
||||
v-model.trim="taglineForm"
|
||||
:counter="validations.userTagline.max"
|
||||
:label="$t('author.taglineLabel')"
|
||||
:rules="[$util.$vc.require($t('author.taglineLabel')), $util.$vc.maxLength(validations.userTagline.max)]"
|
||||
:rules="[$util.$vc.required($t('author.taglineLabel')), $util.$vc.maxLength(validations.userTagline.max)]"
|
||||
/>
|
||||
<template #other-btns>
|
||||
<v-btn color="info" text :loading="loading.resetTagline" :disabled="!user.tagline" @click.stop="resetTagline">
|
||||
|
@ -13,7 +13,7 @@
|
||||
counter="255"
|
||||
:loading="validateLoading"
|
||||
:error-messages="nameErrorMessages"
|
||||
:rules="[$util.$vc.require($t('apiKeys.name')), $util.$vc.maxLength(255), $util.$vc.minLength(5)]"
|
||||
:rules="[$util.$vc.required($t('apiKeys.name')), $util.$vc.maxLength(255), $util.$vc.minLength(5)]"
|
||||
>
|
||||
<template #append-outer>
|
||||
<v-btn color="success" class="input-append-btn" :disabled="!validForm" :loading="loading" @click="create">
|
||||
|
@ -48,7 +48,7 @@
|
||||
item-text="name"
|
||||
item-value="userId"
|
||||
:label="$t('project.new.step2.userSelect')"
|
||||
:rules="[$util.$vc.require($t('project.new.step2.userSelect'))]"
|
||||
:rules="[$util.$vc.required($t('project.new.step2.userSelect'))]"
|
||||
:append-icon="createAsIcon"
|
||||
/>
|
||||
</v-col>
|
||||
@ -60,7 +60,7 @@
|
||||
filled
|
||||
:error-messages="nameErrors"
|
||||
:label="$t('project.new.step2.projectName')"
|
||||
:rules="[$util.$vc.require($t('project.new.step2.projectName'))]"
|
||||
:rules="[$util.$vc.required($t('project.new.step2.projectName'))]"
|
||||
append-icon="mdi-form-textbox"
|
||||
/>
|
||||
</v-col>
|
||||
@ -71,7 +71,7 @@
|
||||
filled
|
||||
clearable
|
||||
:label="$t('project.new.step2.projectSummary')"
|
||||
:rules="[$util.$vc.require($t('project.new.step2.projectSummary')), $util.$vc.maxLength(validations.project.desc.max)]"
|
||||
:rules="[$util.$vc.required($t('project.new.step2.projectSummary')), $util.$vc.maxLength(validations.project.desc.max)]"
|
||||
append-icon="mdi-card-text"
|
||||
/>
|
||||
</v-col>
|
||||
@ -85,7 +85,7 @@
|
||||
:label="$t('project.new.step2.projectCategory')"
|
||||
item-text="title"
|
||||
item-value="apiName"
|
||||
:rules="[$util.$vc.require($t('project.new.step2.projectCategory'))]"
|
||||
:rules="[$util.$vc.required($t('project.new.step2.projectCategory'))]"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
@ -13,7 +13,7 @@
|
||||
:loading="validateLoading"
|
||||
:label="$t('organization.new.name')"
|
||||
:rules="[
|
||||
$util.$vc.require($t('organization.new.name')),
|
||||
$util.$vc.required($t('organization.new.name')),
|
||||
$util.$vc.regex($t('organization.new.name'), validations.org.regex),
|
||||
$util.$vc.minLength(validations.org.min),
|
||||
$util.$vc.maxLength(validations.org.max),
|
||||
|
@ -6,11 +6,6 @@ import { AuthState } from '~/store/auth';
|
||||
const createAuth = ({ app: { $cookies }, $axios, store, $api, redirect }: Context) => {
|
||||
class Auth {
|
||||
login(redirect: string): void {
|
||||
$cookies.set('returnRoute', redirect, {
|
||||
path: '/',
|
||||
maxAge: 120,
|
||||
secure: process.env.nodeEnv === 'production',
|
||||
});
|
||||
location.replace(`/login?returnUrl=${process.env.publicHost}${redirect}`);
|
||||
}
|
||||
|
||||
@ -23,7 +18,7 @@ const createAuth = ({ app: { $cookies }, $axios, store, $api, redirect }: Contex
|
||||
store.commit('auth/SET_USER', null);
|
||||
store.commit('auth/SET_TOKEN', null);
|
||||
store.commit('auth/SET_AUTHED', false);
|
||||
await $axios.get('/invalidate');
|
||||
await $axios.get('/invalidate').catch(() => console.log('invalidate failed'));
|
||||
$cookies.remove('HangarAuth_REFRESH', {
|
||||
path: '/',
|
||||
});
|
||||
@ -57,7 +52,7 @@ const createAuth = ({ app: { $cookies }, $axios, store, $api, redirect }: Contex
|
||||
|
||||
refreshUser(): Promise<void> {
|
||||
return $api.getToken(true).then((token) => {
|
||||
if (token != null) {
|
||||
if (token) {
|
||||
if (store.state.auth.authenticated) {
|
||||
return this.updateUser(token);
|
||||
} else {
|
||||
|
@ -159,7 +159,7 @@ const createUtil = ({ store, error, app: { i18n } }: Context) => {
|
||||
*/
|
||||
hasPerms(...namedPermission: NamedPermission[]): boolean {
|
||||
const perms = (store.state.auth as AuthState).routePermissions;
|
||||
if (perms === null) return false;
|
||||
if (!perms) return false;
|
||||
const _perms: bigint = BigInt('0b' + perms);
|
||||
let result = true;
|
||||
for (const np of namedPermission) {
|
||||
@ -239,7 +239,7 @@ const createUtil = ({ store, error, app: { i18n } }: Context) => {
|
||||
}
|
||||
|
||||
$vc = {
|
||||
require:
|
||||
required:
|
||||
(name: TranslateResult = 'Field') =>
|
||||
(v: string) =>
|
||||
!!v || i18n.t('validation.required', [name]),
|
||||
|
@ -1967,6 +1967,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.3.tgz#9c088679876f374eb5983f150d4787aa6fb32d7e"
|
||||
integrity sha512-FvUupuM3rlRsRtCN+fDudtmytGO6iHJuuRKS1Ss0pG5z8oX0diNEw94UEL7hgDbpN94rgaK5R7sWm6RrSkZuAQ==
|
||||
|
||||
"@types/uuid@8.3.1":
|
||||
version "8.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.1.tgz#1a32969cf8f0364b3d8c8af9cc3555b7805df14f"
|
||||
integrity sha512-Y2mHTRAbqfFkpjldbkHGY8JIzRN6XqYRliG8/24FcHm2D2PwW24fl5xMRTVGdrb7iMrwCaIEbLWerGIkXuFWVg==
|
||||
|
||||
"@types/webpack-bundle-analyzer@3.9.3":
|
||||
version "3.9.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/webpack-bundle-analyzer/-/webpack-bundle-analyzer-3.9.3.tgz#3a12025eb5d86069c30b47a157e62c0aca6e39a1"
|
||||
@ -9213,7 +9218,7 @@ sass-loader@^10.2.0:
|
||||
schema-utils "^3.0.0"
|
||||
semver "^7.3.2"
|
||||
|
||||
sass@1.32.13, sass@~1.32.13:
|
||||
sass@~1.32.13:
|
||||
version "1.32.13"
|
||||
resolved "https://registry.yarnpkg.com/sass/-/sass-1.32.13.tgz#8d29c849e625a415bce71609c7cf95e15f74ed00"
|
||||
integrity sha512-dEgI9nShraqP7cXQH+lEXVf73WOPCse0QlFzSD8k+1TcOxCMwVXfQlr0jtoluZysQOyJGnfr21dLvYKDJq8HkA==
|
||||
@ -10485,6 +10490,11 @@ utils-merge@1.0.1:
|
||||
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
|
||||
integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=
|
||||
|
||||
uuid@8.3.2:
|
||||
version "8.3.2"
|
||||
resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"
|
||||
integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==
|
||||
|
||||
v8-compile-cache@^2.0.3:
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee"
|
||||
|
@ -23,7 +23,6 @@ public class HangarConfig {
|
||||
private List<Sponsor> sponsors;
|
||||
|
||||
private boolean dev = true;
|
||||
private String authUrl;
|
||||
private String pluginUploadDir = new ApplicationHome(HangarApplication.class).getDir().toPath().resolve("work").toString();
|
||||
private String baseUrl;
|
||||
private String gaCode = "";
|
||||
@ -152,14 +151,6 @@ public class HangarConfig {
|
||||
this.dev = dev;
|
||||
}
|
||||
|
||||
public String getAuthUrl() {
|
||||
return authUrl;
|
||||
}
|
||||
|
||||
public void setAuthUrl(String authUrl) {
|
||||
this.authUrl = authUrl;
|
||||
}
|
||||
|
||||
public String getPluginUploadDir() {
|
||||
return pluginUploadDir;
|
||||
}
|
||||
|
@ -3,23 +3,18 @@ package io.papermc.hangar.config.hangar;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "hangar.sso")
|
||||
public class SSOConfig {
|
||||
|
||||
// TODO weed out the useless settings
|
||||
private boolean enabled = true;
|
||||
private String loginUrl = "/sso/";
|
||||
private String signupUrl = "/sso/signup/";
|
||||
private String verifyUrl = "/sso/sudo/";
|
||||
private String logoutUrl = "/accounts/logout/";
|
||||
private String avatarUrl = "/avatar/%s?size=120x120";
|
||||
private String secret = "changeme";
|
||||
private String apiKey = "changeme";
|
||||
private Duration timeout = Duration.ofSeconds(2);
|
||||
private Duration reset = Duration.ofMinutes(10);
|
||||
private String oauthUrl = "http://localhost:4444";
|
||||
private String loginUrl = "/oauth2/auth/";
|
||||
private String tokenUrl = "/oauth2/token";
|
||||
private String clientId = "my-client";
|
||||
|
||||
private String authUrl = "http://localhost:3001";
|
||||
private String signupUrl = "/account/signup";
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
@ -45,59 +40,35 @@ public class SSOConfig {
|
||||
this.signupUrl = signupUrl;
|
||||
}
|
||||
|
||||
public String getVerifyUrl() {
|
||||
return verifyUrl;
|
||||
public String getTokenUrl() {
|
||||
return tokenUrl;
|
||||
}
|
||||
|
||||
public void setVerifyUrl(String verifyUrl) {
|
||||
this.verifyUrl = verifyUrl;
|
||||
public void setTokenUrl(String tokenUrl) {
|
||||
this.tokenUrl = tokenUrl;
|
||||
}
|
||||
|
||||
public String getLogoutUrl() {
|
||||
return logoutUrl;
|
||||
public String getClientId() {
|
||||
return clientId;
|
||||
}
|
||||
|
||||
public void setLogoutUrl(String logoutUrl) {
|
||||
this.logoutUrl = logoutUrl;
|
||||
public void setClientId(String clientId) {
|
||||
this.clientId = clientId;
|
||||
}
|
||||
|
||||
public String getAvatarUrl() {
|
||||
return avatarUrl;
|
||||
public String getAuthUrl() {
|
||||
return authUrl;
|
||||
}
|
||||
|
||||
public void setAvatarUrl(String avatarUrl) {
|
||||
this.avatarUrl = avatarUrl;
|
||||
public void setAuthUrl(String authUrl) {
|
||||
this.authUrl = authUrl;
|
||||
}
|
||||
|
||||
public String getSecret() {
|
||||
return secret;
|
||||
public String getOauthUrl() {
|
||||
return oauthUrl;
|
||||
}
|
||||
|
||||
public void setSecret(String secret) {
|
||||
this.secret = secret;
|
||||
}
|
||||
|
||||
public String getApiKey() {
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
public void setApiKey(String apiKey) {
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
public Duration getTimeout() {
|
||||
return timeout;
|
||||
}
|
||||
|
||||
public void setTimeout(Duration timeout) {
|
||||
this.timeout = timeout;
|
||||
}
|
||||
|
||||
public Duration getReset() {
|
||||
return reset;
|
||||
}
|
||||
|
||||
public void setReset(Duration reset) {
|
||||
this.reset = reset;
|
||||
public void setOauthUrl(String oauthUrl) {
|
||||
this.oauthUrl = oauthUrl;
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,18 @@
|
||||
package io.papermc.hangar.controller;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.CookieValue;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.servlet.view.RedirectView;
|
||||
|
||||
import javax.servlet.http.Cookie;
|
||||
import javax.servlet.http.HttpSession;
|
||||
|
||||
import io.papermc.hangar.HangarComponent;
|
||||
import io.papermc.hangar.exceptions.HangarApiException;
|
||||
import io.papermc.hangar.model.api.auth.RefreshResponse;
|
||||
@ -13,20 +26,6 @@ import io.papermc.hangar.service.ValidationService;
|
||||
import io.papermc.hangar.service.internal.auth.SSOService;
|
||||
import io.papermc.hangar.service.internal.perms.roles.GlobalRoleService;
|
||||
import io.papermc.hangar.service.internal.users.UserService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.CookieValue;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
import org.springframework.web.servlet.view.RedirectView;
|
||||
|
||||
import javax.servlet.http.Cookie;
|
||||
import javax.servlet.http.HttpSession;
|
||||
|
||||
@Controller
|
||||
public class LoginController extends HangarComponent {
|
||||
@ -62,9 +61,9 @@ public class LoginController extends HangarComponent {
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping(path = "/login", params = {"sso", "sig"})
|
||||
public RedirectView loginFromAuth(@RequestParam String sso, @RequestParam String sig, @CookieValue String url, RedirectAttributes attributes) {
|
||||
AuthUser authUser = ssoService.authenticate(sso, sig);
|
||||
@GetMapping(path = "/login", params = {"code", "state"})
|
||||
public RedirectView loginFromAuth(@RequestParam String code, @RequestParam String state, @CookieValue String url) {
|
||||
AuthUser authUser = ssoService.authenticate(code, state, config.getBaseUrl() + "/login");
|
||||
if (authUser == null) {
|
||||
throw new HangarApiException("nav.user.error.loginFailed");
|
||||
}
|
||||
@ -76,8 +75,8 @@ public class LoginController extends HangarComponent {
|
||||
UserTable user = userService.getOrCreate(authUser.getUserName(), authUser);
|
||||
globalRoleService.removeAllGlobalRoles(user.getId());
|
||||
authUser.getGlobalRoles().forEach(globalRole -> globalRoleService.addRole(globalRole.create(null, user.getId(), true)));
|
||||
String token = tokenService.createTokenForUser(user);
|
||||
return redirectBackOnSuccessfulLogin(url + "?token=" + token, user);
|
||||
tokenService.createTokenForUser(user);
|
||||
return redirectBackOnSuccessfulLogin(url);
|
||||
}
|
||||
|
||||
@GetMapping("/refresh")
|
||||
@ -95,24 +94,15 @@ public class LoginController extends HangarComponent {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO needed?
|
||||
@PostMapping("/verify")
|
||||
public RedirectView verify(@RequestParam String returnPath) {
|
||||
if (config.fakeUser.isEnabled()) {
|
||||
throw new HangarApiException("nav.user.error.fakeUserEnabled", "Verififcation");
|
||||
}
|
||||
return redirectToSso(ssoService.getVerifyUrl(config.getBaseUrl() + returnPath));
|
||||
}
|
||||
|
||||
@GetMapping("/signup")
|
||||
public RedirectView signUp(@RequestParam(defaultValue = "") String returnUrl) {
|
||||
if (config.fakeUser.isEnabled()) {
|
||||
throw new HangarApiException("nav.user.error.fakeUserEnabled", "Signup");
|
||||
}
|
||||
return redirectToSso(ssoService.getSignupUrl(returnUrl));
|
||||
return new RedirectView(ssoService.getSignupUrl(returnUrl));
|
||||
}
|
||||
|
||||
private RedirectView redirectBackOnSuccessfulLogin(String url, UserTable user) {
|
||||
private RedirectView redirectBackOnSuccessfulLogin(String url) {
|
||||
if (!url.startsWith("http")) {
|
||||
if (url.startsWith("/")) {
|
||||
url = config.getBaseUrl() + url;
|
||||
|
@ -1,78 +0,0 @@
|
||||
package io.papermc.hangar.controller.api;
|
||||
|
||||
import io.papermc.hangar.HangarComponent;
|
||||
import io.papermc.hangar.config.hangar.SSOConfig;
|
||||
import io.papermc.hangar.exceptions.HangarApiException;
|
||||
import io.papermc.hangar.model.internal.sso.SsoSyncData;
|
||||
import io.papermc.hangar.service.internal.auth.SSOService;
|
||||
import io.papermc.hangar.service.internal.users.UserService;
|
||||
import io.swagger.annotations.Api;
|
||||
import io.swagger.annotations.ApiOperation;
|
||||
import io.swagger.annotations.ApiResponse;
|
||||
import io.swagger.annotations.ApiResponses;
|
||||
import io.swagger.annotations.Authorization;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMethod;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import java.util.Map;
|
||||
|
||||
@Controller
|
||||
@Api(tags = "Sessions (Authentication)", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
|
||||
@RequestMapping(path = "/api", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE, method = RequestMethod.POST)
|
||||
public class SSOSyncController extends HangarComponent {
|
||||
|
||||
private final SSOService ssoService;
|
||||
private final UserService userService;
|
||||
private final SSOConfig ssoConfig;
|
||||
|
||||
@Autowired
|
||||
public SSOSyncController(SSOService ssoService, UserService userService, SSOConfig ssoConfig) {
|
||||
this.ssoService = ssoService;
|
||||
this.userService = userService;
|
||||
this.ssoConfig = ssoConfig;
|
||||
}
|
||||
|
||||
@ApiOperation(
|
||||
value = "Syncs SSO data to Hangar",
|
||||
nickname = "syncSso",
|
||||
notes = "Syncs data for a user from a SpongeAuth-compatible SSO provider. The SSO provider must be provided with this endpoint, as well as with a secret and API key that matches this Hangar instance.",
|
||||
authorizations = @Authorization("SSO"),
|
||||
tags = "Sessions (Authentication)"
|
||||
)
|
||||
@ApiResponses({
|
||||
@ApiResponse(code = 200, message = "Ok"),
|
||||
@ApiResponse(code = 401, message = "Sent if the signature or API key missing or invalid.")
|
||||
})
|
||||
@PostMapping(value = "/sync_sso", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
|
||||
public ResponseEntity<MultiValueMap<String, String>> syncSso(@RequestParam @NotEmpty String sso, @RequestParam @NotEmpty String sig, @RequestParam("api_key") @NotEmpty String apiKey) {
|
||||
if (!apiKey.equals(ssoConfig.getApiKey())) {
|
||||
logger.warn("SSO sync failed: bad API key ({} provided, {} expected)", apiKey, ssoConfig.getApiKey());
|
||||
throw new HangarApiException(HttpStatus.BAD_REQUEST, "SSO sync failed: bad API key (" + apiKey + " provided, " + ssoConfig.getApiKey() + " expected)");
|
||||
}
|
||||
|
||||
try {
|
||||
Map<String, String> map = ssoService.decode(sso, sig);
|
||||
SsoSyncData data = SsoSyncData.fromSignedPayload(map);
|
||||
userService.ssoSyncUser(data);
|
||||
logger.debug("SSO sync successful: {}", map);
|
||||
MultiValueMap<String, String> ssoResponse = new LinkedMultiValueMap<>();
|
||||
ssoResponse.set("status", "success");
|
||||
return ResponseEntity.ok(ssoResponse);
|
||||
} catch (SSOService.SignatureException e) {
|
||||
logger.warn("SSO sync failed: invalid signature ({} for data {})", sig, sso);
|
||||
throw new HangarApiException(HttpStatus.BAD_REQUEST, "SSO sync failed: invalid signature (" + sig + " for data " + sso + ")");
|
||||
}
|
||||
}
|
||||
}
|
@ -17,15 +17,17 @@ public class AuthUser {
|
||||
private final String email;
|
||||
private final String avatarUrl;
|
||||
private final Locale lang;
|
||||
private final String fullName;
|
||||
private final List<GlobalRole> globalRoles;
|
||||
|
||||
@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
|
||||
public AuthUser(long id, String userName, String email, String avatarUrl, Locale lang, String addGroups) {
|
||||
public AuthUser(long id, String userName, String email, String avatarUrl, Locale lang, String fullName, String addGroups) {
|
||||
this.id = id;
|
||||
this.userName = userName;
|
||||
this.email = email;
|
||||
this.avatarUrl = avatarUrl;
|
||||
this.lang = lang;
|
||||
this.fullName = fullName;
|
||||
if (addGroups == null || addGroups.isBlank()) {
|
||||
this.globalRoles = new ArrayList<>();
|
||||
} else {
|
||||
@ -39,6 +41,7 @@ public class AuthUser {
|
||||
this.email = email;
|
||||
this.avatarUrl = "";
|
||||
this.lang = Locale.ENGLISH;
|
||||
this.fullName = null;
|
||||
this.globalRoles = new ArrayList<>();
|
||||
}
|
||||
|
||||
@ -66,6 +69,10 @@ public class AuthUser {
|
||||
return globalRoles;
|
||||
}
|
||||
|
||||
public String getFullName() {
|
||||
return fullName;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "AuthUser{" +
|
||||
|
@ -0,0 +1,102 @@
|
||||
package io.papermc.hangar.model.internal.sso;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public class TokenResponce {
|
||||
|
||||
@JsonProperty("access_token")
|
||||
private String accessToken;
|
||||
@JsonProperty("expires_in")
|
||||
private long expiresIn;
|
||||
@JsonProperty("id_token")
|
||||
private String idToken;
|
||||
@JsonProperty("refresh_token")
|
||||
private String refreshToken;
|
||||
private String scope;
|
||||
@JsonProperty("token_type")
|
||||
private String tokenType;
|
||||
|
||||
public TokenResponce(String accessToken, long expiresIn, String idToken, String refreshToken, String scope, String tokenType) {
|
||||
this.accessToken = accessToken;
|
||||
this.expiresIn = expiresIn;
|
||||
this.idToken = idToken;
|
||||
this.refreshToken = refreshToken;
|
||||
this.scope = scope;
|
||||
this.tokenType = tokenType;
|
||||
}
|
||||
|
||||
public String getAccessToken() {
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
public void setAccessToken(String accessToken) {
|
||||
this.accessToken = accessToken;
|
||||
}
|
||||
|
||||
public long getExpiresIn() {
|
||||
return expiresIn;
|
||||
}
|
||||
|
||||
public void setExpiresIn(long expiresIn) {
|
||||
this.expiresIn = expiresIn;
|
||||
}
|
||||
|
||||
public String getIdToken() {
|
||||
return idToken;
|
||||
}
|
||||
|
||||
public void setIdToken(String idToken) {
|
||||
this.idToken = idToken;
|
||||
}
|
||||
|
||||
public String getRefreshToken() {
|
||||
return refreshToken;
|
||||
}
|
||||
|
||||
public void setRefreshToken(String refreshToken) {
|
||||
this.refreshToken = refreshToken;
|
||||
}
|
||||
|
||||
public String getScope() {
|
||||
return scope;
|
||||
}
|
||||
|
||||
public void setScope(String scope) {
|
||||
this.scope = scope;
|
||||
}
|
||||
|
||||
public String getTokenType() {
|
||||
return tokenType;
|
||||
}
|
||||
|
||||
public void setTokenType(String tokenType) {
|
||||
this.tokenType = tokenType;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "TokenResponce{" +
|
||||
"accessToken='" + accessToken + '\'' +
|
||||
", expiresIn=" + expiresIn +
|
||||
", idToken='" + idToken + '\'' +
|
||||
", refreshToken='" + refreshToken + '\'' +
|
||||
", scope='" + scope + '\'' +
|
||||
", tokenType='" + tokenType + '\'' +
|
||||
'}';
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
TokenResponce that = (TokenResponce) o;
|
||||
return expiresIn == that.expiresIn && Objects.equals(accessToken, that.accessToken) && Objects.equals(idToken, that.idToken) && Objects.equals(refreshToken, that.refreshToken) && Objects.equals(scope, that.scope) && Objects.equals(tokenType, that.tokenType);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(accessToken, expiresIn, idToken, refreshToken, scope, tokenType);
|
||||
}
|
||||
}
|
137
src/main/java/io/papermc/hangar/model/internal/sso/Traits.java
Normal file
137
src/main/java/io/papermc/hangar/model/internal/sso/Traits.java
Normal file
@ -0,0 +1,137 @@
|
||||
package io.papermc.hangar.model.internal.sso;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public class Traits {
|
||||
|
||||
private String email;
|
||||
private String username;
|
||||
private String discord;
|
||||
private String github;
|
||||
private String minecraft;
|
||||
private String language;
|
||||
private Name name;
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
public void setEmail(String email) {
|
||||
this.email = email;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public void setUsername(String username) {
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
public String getDiscord() {
|
||||
return discord;
|
||||
}
|
||||
|
||||
public void setDiscord(String discord) {
|
||||
this.discord = discord;
|
||||
}
|
||||
|
||||
public String getGithub() {
|
||||
return github;
|
||||
}
|
||||
|
||||
public void setGithub(String github) {
|
||||
this.github = github;
|
||||
}
|
||||
|
||||
public String getMinecraft() {
|
||||
return minecraft;
|
||||
}
|
||||
|
||||
public void setMinecraft(String minecraft) {
|
||||
this.minecraft = minecraft;
|
||||
}
|
||||
|
||||
public Name getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(Name name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public String getLanguage() {
|
||||
return language;
|
||||
}
|
||||
|
||||
public void setLanguage(String language) {
|
||||
this.language = language;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Traits{" +
|
||||
"email='" + email + '\'' +
|
||||
", username='" + username + '\'' +
|
||||
", discord='" + discord + '\'' +
|
||||
", github='" + github + '\'' +
|
||||
", minecraft='" + minecraft + '\'' +
|
||||
", name=" + name +
|
||||
'}';
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
Traits traits = (Traits) o;
|
||||
return Objects.equals(email, traits.email) && Objects.equals(username, traits.username) && Objects.equals(discord, traits.discord) && Objects.equals(github, traits.github) && Objects.equals(minecraft, traits.minecraft) && Objects.equals(name, traits.name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(email, username, discord, github, minecraft, name);
|
||||
}
|
||||
|
||||
public static class Name {
|
||||
private String first;
|
||||
private String last;
|
||||
|
||||
public String getFirst() {
|
||||
return first;
|
||||
}
|
||||
|
||||
public void setFirst(String first) {
|
||||
this.first = first;
|
||||
}
|
||||
|
||||
public String getLast() {
|
||||
return last;
|
||||
}
|
||||
|
||||
public void setLast(String last) {
|
||||
this.last = last;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Name{" +
|
||||
"first='" + first + '\'' +
|
||||
", last='" + last + '\'' +
|
||||
'}';
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
Name name = (Name) o;
|
||||
return Objects.equals(first, name.first) && Objects.equals(last, name.last);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(first, last);
|
||||
}
|
||||
}
|
||||
}
|
@ -60,7 +60,7 @@ public class AuthenticationService extends HangarComponent {
|
||||
|
||||
public URI changeAvatarUri(String requester, String organization) throws JsonProcessingException {
|
||||
ChangeAvatarToken token = getChangeAvatarToken(requester, organization);
|
||||
UriComponentsBuilder uriComponents = UriComponentsBuilder.fromHttpUrl(config.getAuthUrl());
|
||||
UriComponentsBuilder uriComponents = UriComponentsBuilder.fromHttpUrl(config.sso.getAuthUrl());
|
||||
uriComponents.path("/accounts/user/{organization}/change-avatar/").queryParam("key", token.getSignedData());
|
||||
return uriComponents.build(organization);
|
||||
}
|
||||
@ -69,7 +69,9 @@ public class AuthenticationService extends HangarComponent {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
|
||||
MultiValueMap<String, String> bodyMap = new LinkedMultiValueMap<>();
|
||||
bodyMap.add("api-key", config.sso.getApiKey());
|
||||
// TODO allow changing org avatars in SSO
|
||||
if (true) throw new RuntimeException("disabled");
|
||||
// bodyMap.add("api-key", config.sso.getApiKey());
|
||||
bodyMap.add("request_username", requester);
|
||||
ChangeAvatarToken token;
|
||||
token = mapper.treeToValue(restTemplate.postForObject(config.security.api.getUrl() + "/api/users/" + organization + "/change-avatar-token/", new HttpEntity<>(bodyMap, headers), ObjectNode.class), ChangeAvatarToken.class);
|
||||
|
@ -1,48 +1,48 @@
|
||||
package io.papermc.hangar.service.internal.auth;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import io.papermc.hangar.config.hangar.HangarConfig;
|
||||
import io.papermc.hangar.db.dao.internal.table.auth.UserSignOnDAO;
|
||||
import io.papermc.hangar.exceptions.HangarApiException;
|
||||
import io.papermc.hangar.model.db.auth.UserSignOnTable;
|
||||
import io.papermc.hangar.model.internal.sso.AuthUser;
|
||||
import io.papermc.hangar.model.internal.sso.URLWithNonce;
|
||||
import io.papermc.hangar.util.CryptoUtils;
|
||||
import com.auth0.jwt.JWT;
|
||||
import com.auth0.jwt.interfaces.DecodedJWT;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.net.URLDecoder;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Base64;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
// reference: https://github.com/MiniDigger/HangarAuth/blob/master/spongeauth/sso/discourse_sso.py
|
||||
import io.papermc.hangar.config.hangar.HangarConfig;
|
||||
import io.papermc.hangar.db.dao.internal.table.auth.UserSignOnDAO;
|
||||
import io.papermc.hangar.model.db.auth.UserSignOnTable;
|
||||
import io.papermc.hangar.model.internal.sso.AuthUser;
|
||||
import io.papermc.hangar.model.internal.sso.TokenResponce;
|
||||
import io.papermc.hangar.model.internal.sso.Traits;
|
||||
import io.papermc.hangar.model.internal.sso.URLWithNonce;
|
||||
|
||||
@Service
|
||||
public class SSOService {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(SSOService.class);
|
||||
|
||||
private final HangarConfig hangarConfig;
|
||||
private final UserSignOnDAO userSignOnDAO;
|
||||
private final Cache<String, String> returnUrls;
|
||||
private final RestTemplate restTemplate;
|
||||
|
||||
@Autowired
|
||||
public SSOService(HangarConfig hangarConfig, UserSignOnDAO userSignOnDAO) {
|
||||
public SSOService(HangarConfig hangarConfig, UserSignOnDAO userSignOnDAO, RestTemplate restTemplate) {
|
||||
this.hangarConfig = hangarConfig;
|
||||
this.returnUrls = Caffeine.newBuilder().expireAfterWrite(hangarConfig.sso.getReset()).build();
|
||||
this.userSignOnDAO = userSignOnDAO;
|
||||
this.restTemplate = restTemplate;
|
||||
}
|
||||
|
||||
private boolean isNonceValid(String nonce) {
|
||||
@ -55,47 +55,44 @@ public class SSOService {
|
||||
}
|
||||
|
||||
public URLWithNonce getLoginUrl(String returnUrl) {
|
||||
return getUrl(returnUrl, hangarConfig.sso.getLoginUrl());
|
||||
}
|
||||
|
||||
public URLWithNonce getSignupUrl(String returnUrl) {
|
||||
return getUrl(returnUrl, hangarConfig.sso.getSignupUrl());
|
||||
}
|
||||
|
||||
public URLWithNonce getVerifyUrl(String returnUrl) {
|
||||
return getUrl(returnUrl, hangarConfig.sso.getVerifyUrl());
|
||||
}
|
||||
|
||||
private URLWithNonce getUrl(String returnUrl, String baseUrl) {
|
||||
String generatedNonce = nonce();
|
||||
String payload = generatePayload( returnUrl, generatedNonce);
|
||||
String sig = sign(payload);
|
||||
String urlEncoded = URLEncoder.encode(payload, StandardCharsets.UTF_8);
|
||||
return new URLWithNonce(String.format("%s?sso=%s&sig=%s", hangarConfig.getAuthUrl() + baseUrl, urlEncoded, sig), generatedNonce);
|
||||
String url = UriComponentsBuilder.fromUriString(hangarConfig.sso.getOauthUrl() + hangarConfig.sso.getLoginUrl())
|
||||
.queryParam("client_id", hangarConfig.sso.getClientId())
|
||||
.queryParam("scope", "openid email profile")
|
||||
.queryParam("response_type", "code")
|
||||
.queryParam("redirect_uri", returnUrl)
|
||||
.queryParam("state", generatedNonce)
|
||||
.build().toUriString();
|
||||
|
||||
return new URLWithNonce(url, generatedNonce);
|
||||
}
|
||||
|
||||
public String getSignupUrl(String returnUrl) {
|
||||
// TODO figure out what we wanna do here
|
||||
return hangarConfig.sso.getAuthUrl() + hangarConfig.sso.getSignupUrl();
|
||||
}
|
||||
|
||||
private String nonce() {
|
||||
return new BigInteger(130, ThreadLocalRandom.current()).toString(32);
|
||||
}
|
||||
|
||||
private String generatePayload(String returnUrl, String nonce) {
|
||||
String payload = String.format("return_sso_url=%s&nonce=%s", returnUrl, nonce);
|
||||
return new String(Base64.getEncoder().encode(payload.getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
public AuthUser authenticate(String code, String nonce, String returnUrl) {
|
||||
String token = redeemCode(code, returnUrl);
|
||||
DecodedJWT decoded = JWT.decode(token);
|
||||
|
||||
public AuthUser authenticate(String payload, String sig) {
|
||||
Map<String, String> decoded = decode(payload, sig);
|
||||
String nonce = decoded.get("nonce");
|
||||
long externalId = Long.parseLong(decoded.get("external_id"));
|
||||
String username = decoded.get("username");
|
||||
String email = decoded.get("email");
|
||||
Traits traits = decoded.getClaim("traits").as(Traits.class);
|
||||
// long externalId = Long.parseLong(decoded.get("external_id"));
|
||||
// String avatarUrl = decoded.get("avatar_url");
|
||||
Locale language = (traits.getLanguage() == null || traits.getLanguage().isBlank()) ? Locale.ENGLISH : Locale.forLanguageTag(traits.getLanguage());
|
||||
// String addGroups = decoded.get("add_groups");
|
||||
AuthUser authUser = new AuthUser(
|
||||
externalId,
|
||||
username,
|
||||
email,
|
||||
decoded.get("avatar_url"),
|
||||
decoded.get("language") == null ? Locale.ENGLISH : Locale.forLanguageTag(decoded.get("language")),
|
||||
decoded.get("add_groups")
|
||||
-5,
|
||||
traits.getUsername(),
|
||||
traits.getEmail(),
|
||||
"avatarUrl",
|
||||
language,
|
||||
traits.getName().getFirst() + traits.getName().getLast(),
|
||||
"addGroups"
|
||||
);
|
||||
if (!isNonceValid(nonce)) {
|
||||
return null;
|
||||
@ -103,41 +100,24 @@ public class SSOService {
|
||||
return authUser;
|
||||
}
|
||||
|
||||
public String redeemCode(String code, String redirect) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
|
||||
|
||||
MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
|
||||
map.add("code", code);
|
||||
map.add("grant_type", "authorization_code");
|
||||
map.add("client_id", hangarConfig.sso.getClientId());
|
||||
map.add("redirect_uri", redirect);
|
||||
|
||||
HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(map, headers);
|
||||
|
||||
ResponseEntity<TokenResponce> tokenResponse = restTemplate.exchange(hangarConfig.sso.getOauthUrl() + hangarConfig.sso.getTokenUrl(), HttpMethod.POST, entity, TokenResponce.class);
|
||||
|
||||
return tokenResponse.getBody().getIdToken();
|
||||
}
|
||||
|
||||
public UserSignOnTable insert(String nonce) {
|
||||
return userSignOnDAO.insert(new UserSignOnTable(nonce));
|
||||
}
|
||||
|
||||
private String sign(String payload) {
|
||||
try {
|
||||
return CryptoUtils.hmacSha256(hangarConfig.sso.getSecret(), payload.getBytes(StandardCharsets.UTF_8));
|
||||
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
|
||||
LOGGER.warn("Error while singing sso key", e);
|
||||
throw new HangarApiException("nav.user.error.loginFailed");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean verify(String payload, String signature) {
|
||||
return sign(payload).equalsIgnoreCase(signature);
|
||||
}
|
||||
|
||||
// reference: unsign
|
||||
public Map<String, String> decode(String payload, String signature) {
|
||||
if (!verify(payload, signature)) {
|
||||
throw new SignatureException(payload, signature);
|
||||
}
|
||||
|
||||
String decoded = new String(Base64.getDecoder().decode(payload));
|
||||
String querystring = URLDecoder.decode(decoded, StandardCharsets.UTF_8);
|
||||
return UriComponentsBuilder.fromUriString("/?" + querystring).build().getQueryParams().toSingleValueMap();
|
||||
}
|
||||
|
||||
public String getReturnUrl(String nonce) {
|
||||
return returnUrls.getIfPresent(nonce);
|
||||
}
|
||||
|
||||
public static class SignatureException extends HangarApiException {
|
||||
SignatureException(String payload, String signature) {
|
||||
super("nav.user.error.hangarAuth", payload, signature);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -75,7 +75,9 @@ public class OrganizationFactory extends HangarComponent {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
|
||||
MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
|
||||
map.add("api-key", config.sso.getApiKey());
|
||||
// TODO allow creating org users in SSO
|
||||
if (true) throw new RuntimeException("disabled");
|
||||
// map.add("api-key", config.sso.getApiKey());
|
||||
map.add("username", name);
|
||||
map.add("email", dummyEmail);
|
||||
map.add("verified", Boolean.TRUE.toString());
|
||||
|
@ -106,7 +106,7 @@ public class UserService extends HangarComponent {
|
||||
if (user == null) {
|
||||
user = new UserTable(
|
||||
authUser.getId(),
|
||||
null,
|
||||
authUser.getFullName(),
|
||||
authUser.getUserName(),
|
||||
authUser.getEmail(),
|
||||
List.of(),
|
||||
|
@ -36,7 +36,7 @@ spring:
|
||||
# Fake User #
|
||||
#############
|
||||
fake-user:
|
||||
enabled: true
|
||||
enabled: false
|
||||
id: -2
|
||||
name: paper
|
||||
username: paper
|
||||
@ -48,7 +48,6 @@ fake-user:
|
||||
|
||||
hangar:
|
||||
dev: true
|
||||
auth-url: "http://localhost:8000"
|
||||
base-url: "http://localhost:3000"
|
||||
ga-code: "UA-38006759-9"
|
||||
|
||||
@ -122,16 +121,13 @@ hangar:
|
||||
|
||||
sso:
|
||||
enabled: true
|
||||
# relative to auth-url
|
||||
login-url: "/sso/"
|
||||
signup-url: "/sso/signup/"
|
||||
verify-url: "/sso/sudo/"
|
||||
logout-url: "/accounts/logout/"
|
||||
avatar-url: "/avatar/%s?size=120x120"
|
||||
secret: "changeme"
|
||||
api-key: "changeme"
|
||||
timeout: "2s"
|
||||
reset: "10m"
|
||||
oauth-url: "http://localhost:4444"
|
||||
login-url: "/oauth2/auth/"
|
||||
token-url: "/oauth2/token"
|
||||
client-id: "my-client"
|
||||
|
||||
auth-url: "http://localhost:3001"
|
||||
signup-url: "/account/signup/"
|
||||
|
||||
security:
|
||||
secure: false
|
||||
@ -168,6 +164,6 @@ hangar:
|
||||
logging:
|
||||
level:
|
||||
root: INFO
|
||||
# org.springframework: DEBUG
|
||||
org.springframework: DEBUG
|
||||
io.papermc.hangar.service.internal.JobService: DEBUG
|
||||
io.papermc.hangar.config.WebConfig.LoggingInterceptor: DEBUG
|
||||
|
Loading…
Reference in New Issue
Block a user