Change authorized applications page (#17656)

* Change authorized applications page

* Hide revoke button for superapps and suspended accounts

* Clean up db/schema.rb
main
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 DEFAULT_ACCOUNTS_LIMIT = 40
include RateLimitHeaders include RateLimitHeaders
include AccessTokenTrackingConcern
skip_before_action :store_current_location skip_before_action :store_current_location
skip_before_action :require_functional!, unless: :whitelist_mode? 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 module SessionTrackingConcern
extend ActiveSupport::Concern extend ActiveSupport::Concern
UPDATE_SIGN_IN_HOURS = 24 SESSION_UPDATE_FREQUENCY = 24.hours.freeze
included do included do
before_action :set_session_activity before_action :set_session_activity
@ -17,6 +17,6 @@ module SessionTrackingConcern
end end
def session_needs_update? 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
end end

View File

@ -3,7 +3,7 @@
module UserTrackingConcern module UserTrackingConcern
extend ActiveSupport::Concern extend ActiveSupport::Concern
UPDATE_SIGN_IN_FREQUENCY = 24.hours.freeze SIGN_IN_UPDATE_FREQUENCY = 24.hours.freeze
included do included do
before_action :update_user_sign_in before_action :update_user_sign_in
@ -16,6 +16,6 @@ module UserTrackingConcern
end end
def user_needs_sign_in_update? 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
end end

View File

@ -224,4 +224,19 @@ module ApplicationHelper
content_tag(:script, json_escape(json).html_safe, id: 'initial-state', type: 'application/json') content_tag(:script, json_escape(json).html_safe, id: 'initial-state', type: 'application/json')
# rubocop:enable Rails/OutputSafety # rubocop:enable Rails/OutputSafety
end 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 end

View File

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

View File

@ -1,7 +1,6 @@
.container-alt { .container-alt {
width: 700px; width: 700px;
margin: 0 auto; margin: 0 auto;
margin-top: 40px;
@media screen and (max-width: 740px) { @media screen and (max-width: 740px) {
width: 100%; width: 100%;
@ -67,22 +66,20 @@
line-height: 18px; line-height: 18px;
box-sizing: border-box; box-sizing: border-box;
padding: 20px 0; padding: 20px 0;
padding-bottom: 0;
margin-bottom: -30px;
margin-top: 40px; margin-top: 40px;
margin-bottom: 10px;
border-bottom: 1px solid $ui-base-color;
@media screen and (max-width: 440px) { @media screen and (max-width: 440px) {
width: 100%; width: 100%;
margin: 0; margin: 0;
margin-bottom: 10px;
padding: 20px; padding: 20px;
padding-bottom: 0;
} }
.avatar { .avatar {
width: 40px; width: 40px;
height: 40px; height: 40px;
margin-right: 8px; margin-right: 10px;
img { img {
width: 100%; width: 100%;
@ -96,7 +93,7 @@
.name { .name {
flex: 1 1 auto; flex: 1 1 auto;
color: $secondary-text-color; color: $secondary-text-color;
width: calc(100% - 88px); width: calc(100% - 90px);
.username { .username {
display: block; display: block;
@ -110,7 +107,7 @@
display: block; display: block;
font-size: 32px; font-size: 32px;
line-height: 40px; 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) { .oauth-prompt {
margin-top: 40px; 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; 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) update(revoked_at: clock.now.utc)
end 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 def push_to_streaming_api
Redis.current.publish("timeline:access_token:#{id}", Oj.dump(event: :kill)) if revoked? || destroyed? Redis.current.publish("timeline:access_token:#{id}", Oj.dump(event: :kill)) if revoked? || destroyed?
end end

View File

@ -8,4 +8,8 @@ module ApplicationExtension
validates :website, url: true, length: { maximum: 2_000 }, if: :website? validates :website, url: true, length: { maximum: 2_000 }, if: :website?
validates :redirect_uri, length: { maximum: 2_000 } validates :redirect_uri, length: { maximum: 2_000 }
end 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 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' = fa_icon 'sign-out'
.container-alt= yield .container-alt= yield
.modal-layout__mastodon .modal-layout__mastodon
%div %div
%img{alt:'', draggable:'false', src:"#{mascot_url}"} %img{alt: '', draggable: 'false', src: mascot_url }
= render template: 'layouts/application' = render template: 'layouts/application'

View File

@ -1,26 +1,38 @@
- content_for :page_title do - content_for :page_title do
= t('doorkeeper.authorizations.new.title') = t('doorkeeper.authorizations.new.title')
.form-container .form-container.simple_form
.oauth-prompt .oauth-prompt
%h2= t('doorkeeper.authorizations.new.prompt', client_name: @pre_auth.client.name) %h3= t('doorkeeper.authorizations.new.title')
%p %p= t('doorkeeper.authorizations.new.prompt_html', client_name: content_tag(:strong, @pre_auth.client.name))
= t('doorkeeper.authorizations.new.able_to')
!= @pre_auth.scopes.map { |scope| t(scope, scope: [:doorkeeper, :scopes]) }.map { |s| "<strong>#{s}</strong>" }.to_sentence
= form_tag oauth_authorization_path, method: :post, class: 'simple_form' do %h3= t('doorkeeper.authorizations.new.review_permissions')
= 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, class: 'simple_form' do %ul.permissions-list
= hidden_field_tag :client_id, @pre_auth.client.uid - grouped_scopes(@pre_auth.scopes).each do |scope|
= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri %li.permissions-list__item
= hidden_field_tag :state, @pre_auth.state .permissions-list__item__icon
= hidden_field_tag :response_type, @pre_auth.response_type = fa_icon('check')
= hidden_field_tag :scope, @pre_auth.scope .permissions-list__item__text
= button_tag t('doorkeeper.authorizations.buttons.deny'), type: :submit, class: 'negative' .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 - content_for :page_title do
= t('doorkeeper.authorized_applications.index.title') = t('doorkeeper.authorized_applications.index.title')
.table-wrapper %p= t('doorkeeper.authorized_applications.index.description_html')
%table.table
%thead %hr.spacer/
%tr
%th= t('doorkeeper.authorized_applications.index.application') .announcements-list
%th= t('doorkeeper.authorized_applications.index.scopes') - @applications.each do |application|
%th= t('doorkeeper.authorized_applications.index.created_at') .announcements-list__item
%th - if application.website.present?
%tbody = link_to application.name, application.website, target: '_blank', rel: 'noopener noreferrer', class: 'announcements-list__item__title'
- @applications.each do |application| - else
%tr %strong.announcements-list__item__title
%td = application.name
- if application.website.blank? - if application.superapp?
= application.name %span.account-role.moderator= t('doorkeeper.authorized_applications.index.superapp')
- else
= link_to application.name, application.website, target: '_blank', rel: 'noopener noreferrer' .announcements-list__item__action-bar
%th!= application.scopes.map { |scope| t(scope, scope: [:doorkeeper, :scopes]) }.join(', ') .announcements-list__item__meta
%td= l application.created_at - if application.most_recently_used_access_token
%td = t('doorkeeper.authorized_applications.index.last_used_at', date: l(application.most_recently_used_access_token.last_used_at.to_date))
- unless application.superapp? || current_account.suspended? - else
= 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') } = 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 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) 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 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 end
def clean_expired_ip_blocks! def clean_expired_ip_blocks!

View File

@ -60,8 +60,8 @@ en:
error: error:
title: An error has occurred title: An error has occurred
new: new:
able_to: It will be able to 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>"
prompt: Application %{client_name} requests access to your account review_permissions: Review permissions
title: Authorization required title: Authorization required
show: show:
title: Copy this authorization code and paste it to the application. title: Copy this authorization code and paste it to the application.
@ -71,10 +71,12 @@ en:
confirmations: confirmations:
revoke: Are you sure? revoke: Are you sure?
index: index:
application: Application authorized_at: Authorized on %{date}
created_at: Authorized 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.
date_format: "%Y-%m-%d %H:%M:%S" last_used_at: Last used on %{date}
scopes: Scopes never_used: Never used
scopes: Permissions
superapp: Internal
title: Your authorized applications title: Your authorized applications
errors: errors:
messages: messages:
@ -110,6 +112,33 @@ en:
authorized_applications: authorized_applications:
destroy: destroy:
notice: Application revoked. 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: layouts:
admin: admin:
nav: nav:
@ -124,6 +153,7 @@ en:
admin:write: modify all data on the server admin:write: modify all data on the server
admin:write:accounts: perform moderation actions on accounts admin:write:accounts: perform moderation actions on accounts
admin:write:reports: perform moderation actions on reports admin:write:reports: perform moderation actions on reports
crypto: use end-to-end encryption
follow: modify account relationships follow: modify account relationships
push: receive your push notifications push: receive your push notifications
read: read all your account's data read: read all your account's data
@ -143,6 +173,7 @@ en:
write:accounts: modify your profile write:accounts: modify your profile
write:blocks: block accounts and domains write:blocks: block accounts and domains
write:bookmarks: bookmark posts write:bookmarks: bookmark posts
write:conversations: mute and delete conversations
write:favourites: favourite posts write:favourites: favourite posts
write:filters: create filters write:filters: create filters
write:follows: follow people 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. # 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 # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@ -630,6 +630,8 @@ ActiveRecord::Schema.define(version: 2022_02_24_010024) do
t.string "scopes" t.string "scopes"
t.bigint "application_id" t.bigint "application_id"
t.bigint "resource_owner_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 ["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 ["resource_owner_id"], name: "index_oauth_access_tokens_on_resource_owner_id"
t.index ["token"], name: "index_oauth_access_tokens_on_token", unique: true 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