Change authorized applications page (#17656)

* Change authorized applications page

* Hide revoke button for superapps and suspended accounts

* Clean up db/schema.rb
pull/17682/head
Eugen Rochko 2022-03-01 16:48:58 +01:00 committed by GitHub
parent 233f7e6174
commit 50ea54b3ed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 393 additions and 62 deletions

View File

@ -5,6 +5,7 @@ class Api::BaseController < ApplicationController
DEFAULT_ACCOUNTS_LIMIT = 40
include RateLimitHeaders
include AccessTokenTrackingConcern
skip_before_action :store_current_location
skip_before_action :require_functional!, unless: :whitelist_mode?

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
module AccessTokenTrackingConcern
extend ActiveSupport::Concern
ACCESS_TOKEN_UPDATE_FREQUENCY = 24.hours.freeze
included do
before_action :update_access_token_last_used
end
private
def update_access_token_last_used
doorkeeper_token.update_last_used(request) if access_token_needs_update?
end
def access_token_needs_update?
doorkeeper_token.present? && (doorkeeper_token.last_used_at.nil? || doorkeeper_token.last_used_at < ACCESS_TOKEN_UPDATE_FREQUENCY.ago)
end
end

View File

@ -3,7 +3,7 @@
module SessionTrackingConcern
extend ActiveSupport::Concern
UPDATE_SIGN_IN_HOURS = 24
SESSION_UPDATE_FREQUENCY = 24.hours.freeze
included do
before_action :set_session_activity
@ -17,6 +17,6 @@ module SessionTrackingConcern
end
def session_needs_update?
!current_session.nil? && current_session.updated_at < UPDATE_SIGN_IN_HOURS.hours.ago
!current_session.nil? && current_session.updated_at < SESSION_UPDATE_FREQUENCY.ago
end
end

View File

@ -3,7 +3,7 @@
module UserTrackingConcern
extend ActiveSupport::Concern
UPDATE_SIGN_IN_FREQUENCY = 24.hours.freeze
SIGN_IN_UPDATE_FREQUENCY = 24.hours.freeze
included do
before_action :update_user_sign_in
@ -16,6 +16,6 @@ module UserTrackingConcern
end
def user_needs_sign_in_update?
user_signed_in? && (current_user.current_sign_in_at.nil? || current_user.current_sign_in_at < UPDATE_SIGN_IN_FREQUENCY.ago)
user_signed_in? && (current_user.current_sign_in_at.nil? || current_user.current_sign_in_at < SIGN_IN_UPDATE_FREQUENCY.ago)
end
end

View File

@ -224,4 +224,19 @@ module ApplicationHelper
content_tag(:script, json_escape(json).html_safe, id: 'initial-state', type: 'application/json')
# rubocop:enable Rails/OutputSafety
end
def grouped_scopes(scopes)
scope_parser = ScopeParser.new
scope_transformer = ScopeTransformer.new
scopes.each_with_object({}) do |str, h|
scope = scope_transformer.apply(scope_parser.parse(str))
if h[scope.key]
h[scope.key].merge!(scope)
else
h[scope.key] = scope
end
end.values
end
end

View File

@ -907,6 +907,12 @@ a.name-tag,
text-decoration: none;
margin-bottom: 10px;
.account-role {
vertical-align: middle;
}
}
a.announcements-list__item__title {
&:hover,
&:focus,
&:active {
@ -925,6 +931,10 @@ a.name-tag,
align-items: center;
}
&__permissions {
margin-top: 10px;
}
&:last-child {
border-bottom: 0;
}

View File

@ -1,7 +1,6 @@
.container-alt {
width: 700px;
margin: 0 auto;
margin-top: 40px;
@media screen and (max-width: 740px) {
width: 100%;
@ -67,22 +66,20 @@
line-height: 18px;
box-sizing: border-box;
padding: 20px 0;
padding-bottom: 0;
margin-bottom: -30px;
margin-top: 40px;
margin-bottom: 10px;
border-bottom: 1px solid $ui-base-color;
@media screen and (max-width: 440px) {
width: 100%;
margin: 0;
margin-bottom: 10px;
padding: 20px;
padding-bottom: 0;
}
.avatar {
width: 40px;
height: 40px;
margin-right: 8px;
margin-right: 10px;
img {
width: 100%;
@ -96,7 +93,7 @@
.name {
flex: 1 1 auto;
color: $secondary-text-color;
width: calc(100% - 88px);
width: calc(100% - 90px);
.username {
display: block;
@ -110,7 +107,7 @@
display: block;
font-size: 32px;
line-height: 40px;
margin-left: 8px;
margin-left: 10px;
}
}

View File

@ -800,9 +800,41 @@ code {
}
}
}
}
@media screen and (max-width: 740px) and (min-width: 441px) {
margin-top: 40px;
.oauth-prompt {
h3 {
color: $ui-secondary-color;
font-size: 17px;
line-height: 22px;
font-weight: 500;
margin-bottom: 30px;
}
p {
font-size: 14px;
line-height: 18px;
margin-bottom: 30px;
}
.permissions-list {
border: 1px solid $ui-base-color;
border-radius: 4px;
background: darken($ui-base-color, 4%);
margin-bottom: 30px;
}
.actions {
margin: 0 -10px;
display: flex;
form {
box-sizing: border-box;
padding: 0 10px;
flex: 1 1 auto;
min-height: 1px;
width: 50%;
}
}
}
@ -1005,3 +1037,38 @@ code {
display: none;
}
}
.permissions-list {
&__item {
padding: 15px;
color: $ui-secondary-color;
border-bottom: 1px solid lighten($ui-base-color, 4%);
display: flex;
align-items: center;
&__text {
flex: 1 1 auto;
&__title {
font-weight: 500;
}
&__type {
color: $darker-text-color;
}
}
&__icon {
flex: 0 0 auto;
font-size: 18px;
width: 30px;
color: $valid-value-color;
display: flex;
align-items: center;
}
&:last-child {
border-bottom: 0;
}
}
}

View File

@ -11,6 +11,10 @@ module AccessTokenExtension
update(revoked_at: clock.now.utc)
end
def update_last_used(request, clock = Time)
update(last_used_at: clock.now.utc, last_used_ip: request.remote_ip)
end
def push_to_streaming_api
Redis.current.publish("timeline:access_token:#{id}", Oj.dump(event: :kill)) if revoked? || destroyed?
end

View File

@ -8,4 +8,8 @@ module ApplicationExtension
validates :website, url: true, length: { maximum: 2_000 }, if: :website?
validates :redirect_uri, length: { maximum: 2_000 }
end
def most_recently_used_access_token
@most_recently_used_access_token ||= access_tokens.where.not(last_used_at: nil).order(last_used_at: :desc).first
end
end

10
app/lib/scope_parser.rb Normal file
View File

@ -0,0 +1,10 @@
# frozen_string_literal: true
class ScopeParser < Parslet::Parser
rule(:term) { match('[a-z]').repeat(1).as(:term) }
rule(:colon) { str(':') }
rule(:access) { (str('write') | str('read')).as(:access) }
rule(:namespace) { str('admin').as(:namespace) }
rule(:scope) { ((namespace >> colon).maybe >> ((access >> colon >> term) | access | term)).as(:scope) }
root(:scope)
end

View File

@ -0,0 +1,40 @@
# frozen_string_literal: true
class ScopeTransformer < Parslet::Transform
class Scope
DEFAULT_TERM = 'all'
DEFAULT_ACCESS = %w(read write).freeze
attr_reader :namespace, :term
def initialize(scope)
@namespace = scope[:namespace]&.to_s
@access = scope[:access] ? [scope[:access].to_s] : DEFAULT_ACCESS.dup
@term = scope[:term]&.to_s || DEFAULT_TERM
end
def key
@key ||= [@namespace, @term].compact.join('/')
end
def access
@access.join('/')
end
def merge(other_scope)
clone.merge!(other_scope)
end
def merge!(other_scope)
raise ArgumentError unless other_scope.namespace == namespace && other_scope.term == term
@access.concat(other_scope.instance_variable_get('@access'))
@access.uniq!
@access.sort!
self
end
end
rule(scope: subtree(:scope)) { Scope.new(scope) }
end

View File

@ -12,8 +12,9 @@
= fa_icon 'sign-out'
.container-alt= yield
.modal-layout__mastodon
%div
%img{alt:'', draggable:'false', src:"#{mascot_url}"}
%img{alt: '', draggable: 'false', src: mascot_url }
= render template: 'layouts/application'

View File

@ -1,26 +1,38 @@
- content_for :page_title do
= t('doorkeeper.authorizations.new.title')
.form-container
.form-container.simple_form
.oauth-prompt
%h2= t('doorkeeper.authorizations.new.prompt', client_name: @pre_auth.client.name)
%h3= t('doorkeeper.authorizations.new.title')
%p
= t('doorkeeper.authorizations.new.able_to')
!= @pre_auth.scopes.map { |scope| t(scope, scope: [:doorkeeper, :scopes]) }.map { |s| "<strong>#{s}</strong>" }.to_sentence
%p= t('doorkeeper.authorizations.new.prompt_html', client_name: content_tag(:strong, @pre_auth.client.name))
= form_tag oauth_authorization_path, method: :post, class: 'simple_form' do
= hidden_field_tag :client_id, @pre_auth.client.uid
= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri
= hidden_field_tag :state, @pre_auth.state
= hidden_field_tag :response_type, @pre_auth.response_type
= hidden_field_tag :scope, @pre_auth.scope
= button_tag t('doorkeeper.authorizations.buttons.authorize'), type: :submit
%h3= t('doorkeeper.authorizations.new.review_permissions')
= form_tag oauth_authorization_path, method: :delete, class: 'simple_form' do
= hidden_field_tag :client_id, @pre_auth.client.uid
= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri
= hidden_field_tag :state, @pre_auth.state
= hidden_field_tag :response_type, @pre_auth.response_type
= hidden_field_tag :scope, @pre_auth.scope
= button_tag t('doorkeeper.authorizations.buttons.deny'), type: :submit, class: 'negative'
%ul.permissions-list
- grouped_scopes(@pre_auth.scopes).each do |scope|
%li.permissions-list__item
.permissions-list__item__icon
= fa_icon('check')
.permissions-list__item__text
.permissions-list__item__text__title
= t(scope.key, scope: [:doorkeeper, :grouped_scopes, :title])
.permissions-list__item__text__type
= t(scope.access, scope: [:doorkeeper, :grouped_scopes, :access])
.actions
= form_tag oauth_authorization_path, method: :post do
= hidden_field_tag :client_id, @pre_auth.client.uid
= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri
= hidden_field_tag :state, @pre_auth.state
= hidden_field_tag :response_type, @pre_auth.response_type
= hidden_field_tag :scope, @pre_auth.scope
= button_tag t('doorkeeper.authorizations.buttons.authorize'), type: :submit
= form_tag oauth_authorization_path, method: :delete do
= hidden_field_tag :client_id, @pre_auth.client.uid
= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri
= hidden_field_tag :state, @pre_auth.state
= hidden_field_tag :response_type, @pre_auth.response_type
= hidden_field_tag :scope, @pre_auth.scope
= button_tag t('doorkeeper.authorizations.buttons.deny'), type: :submit, class: 'negative'

View File

@ -1,24 +1,44 @@
- content_for :page_title do
= t('doorkeeper.authorized_applications.index.title')
.table-wrapper
%table.table
%thead
%tr
%th= t('doorkeeper.authorized_applications.index.application')
%th= t('doorkeeper.authorized_applications.index.scopes')
%th= t('doorkeeper.authorized_applications.index.created_at')
%th
%tbody
- @applications.each do |application|
%tr
%td
- if application.website.blank?
= application.name
- else
= link_to application.name, application.website, target: '_blank', rel: 'noopener noreferrer'
%th!= application.scopes.map { |scope| t(scope, scope: [:doorkeeper, :scopes]) }.join(', ')
%td= l application.created_at
%td
- unless application.superapp? || current_account.suspended?
= table_link_to 'times', t('doorkeeper.authorized_applications.buttons.revoke'), oauth_authorized_application_path(application), method: :delete, data: { confirm: t('doorkeeper.authorized_applications.confirmations.revoke') }
%p= t('doorkeeper.authorized_applications.index.description_html')
%hr.spacer/
.announcements-list
- @applications.each do |application|
.announcements-list__item
- if application.website.present?
= link_to application.name, application.website, target: '_blank', rel: 'noopener noreferrer', class: 'announcements-list__item__title'
- else
%strong.announcements-list__item__title
= application.name
- if application.superapp?
%span.account-role.moderator= t('doorkeeper.authorized_applications.index.superapp')
.announcements-list__item__action-bar
.announcements-list__item__meta
- if application.most_recently_used_access_token
= t('doorkeeper.authorized_applications.index.last_used_at', date: l(application.most_recently_used_access_token.last_used_at.to_date))
- else
= t('doorkeeper.authorized_applications.index.never_used')
= t('doorkeeper.authorized_applications.index.authorized_at', date: l(application.created_at.to_date))
- unless application.superapp? || current_account.suspended?
%div
= table_link_to 'times', t('doorkeeper.authorized_applications.buttons.revoke'), oauth_authorized_application_path(application), method: :delete, data: { confirm: t('doorkeeper.authorized_applications.confirmations.revoke') }
.announcements-list__item__permissions
%ul.permissions-list
- grouped_scopes(application.scopes).each do |scope|
%li.permissions-list__item
.permissions-list__item__icon
= fa_icon('check')
.permissions-list__item__text
.permissions-list__item__text__title
= t(scope.key, scope: [:doorkeeper, :grouped_scopes, :title])
.permissions-list__item__text__type
= t(scope.access, scope: [:doorkeeper, :grouped_scopes, :access])

View File

@ -18,6 +18,7 @@ class Scheduler::IpCleanupScheduler
SessionActivation.where('updated_at < ?', IP_RETENTION_PERIOD.ago).in_batches.destroy_all
User.where('current_sign_in_at < ?', IP_RETENTION_PERIOD.ago).in_batches.update_all(sign_up_ip: nil)
LoginActivity.where('created_at < ?', IP_RETENTION_PERIOD.ago).in_batches.destroy_all
Doorkeeper::AccessToken.where('last_used_at < ?', IP_RETENTION_PERIOD.ago).in_batches.update_all(last_used_ip: nil)
end
def clean_expired_ip_blocks!

View File

@ -60,8 +60,8 @@ en:
error:
title: An error has occurred
new:
able_to: It will be able to
prompt: Application %{client_name} requests access to your account
prompt_html: "%{client_name} would like permission to access your account. It is a third-party application. <strong>If you do not trust it, then you should not authorize it.</strong>"
review_permissions: Review permissions
title: Authorization required
show:
title: Copy this authorization code and paste it to the application.
@ -71,10 +71,12 @@ en:
confirmations:
revoke: Are you sure?
index:
application: Application
created_at: Authorized
date_format: "%Y-%m-%d %H:%M:%S"
scopes: Scopes
authorized_at: Authorized on %{date}
description_html: These are applications that can access your account using the API. If there are applications you do not recognize here, or an application is misbehaving, you can revoke its access.
last_used_at: Last used on %{date}
never_used: Never used
scopes: Permissions
superapp: Internal
title: Your authorized applications
errors:
messages:
@ -110,6 +112,33 @@ en:
authorized_applications:
destroy:
notice: Application revoked.
grouped_scopes:
access:
read: Read-only access
read/write: Read and write access
write: Write-only access
title:
accounts: Accounts
admin/accounts: Administration of accounts
admin/all: All administrative functions
admin/reports: Administration of reports
all: Everything
blocks: Blocks
bookmarks: Bookmarks
conversations: Conversations
crypto: End-to-end encryption
favourites: Favourites
filters: Filters
follow: Relationships
follows: Follows
lists: Lists
media: Media attachments
mutes: Mutes
notifications: Notifications
push: Push notifications
reports: Reports
search: Search
statuses: Posts
layouts:
admin:
nav:
@ -124,6 +153,7 @@ en:
admin:write: modify all data on the server
admin:write:accounts: perform moderation actions on accounts
admin:write:reports: perform moderation actions on reports
crypto: use end-to-end encryption
follow: modify account relationships
push: receive your push notifications
read: read all your account's data
@ -143,6 +173,7 @@ en:
write:accounts: modify your profile
write:blocks: block accounts and domains
write:bookmarks: bookmark posts
write:conversations: mute and delete conversations
write:favourites: favourite posts
write:filters: create filters
write:follows: follow people

View File

@ -0,0 +1,6 @@
class AddLastUsedAtToOauthAccessTokens < ActiveRecord::Migration[6.1]
def change
add_column :oauth_access_tokens, :last_used_at, :datetime
add_column :oauth_access_tokens, :last_used_ip, :inet
end
end

View File

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2022_02_24_010024) do
ActiveRecord::Schema.define(version: 2022_02_27_041951) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -630,6 +630,8 @@ ActiveRecord::Schema.define(version: 2022_02_24_010024) do
t.string "scopes"
t.bigint "application_id"
t.bigint "resource_owner_id"
t.datetime "last_used_at"
t.inet "last_used_ip"
t.index ["refresh_token"], name: "index_oauth_access_tokens_on_refresh_token", unique: true
t.index ["resource_owner_id"], name: "index_oauth_access_tokens_on_resource_owner_id"
t.index ["token"], name: "index_oauth_access_tokens_on_token", unique: true

View File

@ -0,0 +1,89 @@
# frozen_string_literal: true
require 'rails_helper'
describe ScopeTransformer do
describe '#apply' do
subject { described_class.new.apply(ScopeParser.new.parse(input)) }
shared_examples 'a scope' do |namespace, term, access|
it 'parses the term' do
expect(subject.term).to eq term
end
it 'parses the namespace' do
expect(subject.namespace).to eq namespace
end
it 'parses the access' do
expect(subject.access).to eq access
end
end
context 'for scope "read"' do
let(:input) { 'read' }
it_behaves_like 'a scope', nil, 'all', 'read'
end
context 'for scope "write"' do
let(:input) { 'write' }
it_behaves_like 'a scope', nil, 'all', 'write'
end
context 'for scope "follow"' do
let(:input) { 'follow' }
it_behaves_like 'a scope', nil, 'follow', 'read/write'
end
context 'for scope "crypto"' do
let(:input) { 'crypto' }
it_behaves_like 'a scope', nil, 'crypto', 'read/write'
end
context 'for scope "push"' do
let(:input) { 'push' }
it_behaves_like 'a scope', nil, 'push', 'read/write'
end
context 'for scope "admin:read"' do
let(:input) { 'admin:read' }
it_behaves_like 'a scope', 'admin', 'all', 'read'
end
context 'for scope "admin:write"' do
let(:input) { 'admin:write' }
it_behaves_like 'a scope', 'admin', 'all', 'write'
end
context 'for scope "admin:read:accounts"' do
let(:input) { 'admin:read:accounts' }
it_behaves_like 'a scope', 'admin', 'accounts', 'read'
end
context 'for scope "admin:write:accounts"' do
let(:input) { 'admin:write:accounts' }
it_behaves_like 'a scope', 'admin', 'accounts', 'write'
end
context 'for scope "read:accounts"' do
let(:input) { 'read:accounts' }
it_behaves_like 'a scope', nil, 'accounts', 'read'
end
context 'for scope "write:accounts"' do
let(:input) { 'write:accounts' }
it_behaves_like 'a scope', nil, 'accounts', 'write'
end
end
end