狛ログ

2022年5月23日月曜日

【Red Hat Linux 8.2】ActiveDirectoryユーザでWebアプリのBASIC認証しようとしたらハマった話。

技術部のyuckieee(ゆっきー)です!
最近、RedHatLinux上でWebアプリ(Web/APサーバ設定含む)を行ったのですが、その際にハマったエラーの解決策をメモしておきます。ググっても、RedHatLinux公式のカスタマーポータル等で探しても、中々それらしき記述がなく、めちゃくちゃ時間を潰しました。

■発生事象

今回は、外部ActiveDirecotry上に設定されたID/パスワードを使用して、WebアプリのBASIC認証を行う形をとっていました。
ですが、何度認証を行ってもBASIC認証ダイアログが出続けログインが出来ません。
SSSDのログには、BASIC認証を行ったタイミングで以下のようなエラーが表示されていました。
March 07 00:00:00 httpd [999999]: pam_sss(webapp:auth): authentication success; logname= uid=0 euid=0 tty= ruser= rhost= user=user_name
March 07 00:00:00 httpd [999999]: pam_sss(webapp:account): Access denied for user user_name: 6 (Permission denied)
内容を見ると、認証は成功するのに該当ユーザの権限が無いためにエラーとなっているように見えます。ただ、ActiveDirectoryにはユーザは間違いなく登録されてており、該当サーバへのアクセスが許可されたグループに所属、現にSSHでは接続できていました。謎です。

■発生環境

[構成]
 OS:RedHatLinux 8.2
 Web/Apサーバ:Apache 2.4.xx
  - BASIC認証のため、mod_authnz_pamモジュール使用
  - WebアプリはPython3.xを使用して構築(Apacheと連携させるため、mod_httpdを使用)
 認証:PAM
 ActiveDirecotry連携:SSSD 2.x

具体的なBASIC認証の設定は以下サイトを参考に行いました。
[参考]Apache module mod_authnz_pam
※pamの設定のみ以下のように変更して、ローカルユーザでの認証もできるようにしていました。
#%PAM-1.0
auth    include system-auth
account include system-auth
session include system-auth

■事象分析

エラー内容より「pam_sssによるwebapp(参考サイトでtlwikiとなっていたPAMサービス名)のaccountチェックにおいてアクセス権限エラーが出ている」と言うことは分かります。
そのため、pam_sssaccountを呼び出している箇所をコメントアウトしてみました。
/etc/pam.d/system-auth
#account     [default=bad success=ok user_unknown=ignore] pam_sss.so ←こちらコメントアウト

まさかですが、これでBASIC認証自体は可能となりました。

同じpam_sssを使用しているであろうSSH接続では発生していないことから、Webアプリ限定のエラーだと分かります。 ちなみにaccountは以下の役割を果たしているそうです。(10.2. PAM 設定ファイルについて)
account: このモジュールインターフェースは、アクセスが許可されることを確認します。
たとえば、ユーザーアカウントの有効期限が切れたか、または特定の時間にユーザーがログインできるかどうかを確認します。
アクセス許可の確認と書いてあるとおり、コメントアウトすることでGPOベースのアクセス制御が外れた状態になることが分かりました。
というか、このGPOベースのアクセス制御の設定に問題が有りました。

■発生原因

SSSDのバージョンアップに際し、セキュリティ向上の観点からデフォルトでActiveDirectoryのGPOベースのアクセス制御が有効となったようです。
さらに、本アクセス制御対象はデフォルト以外のPAMサービスがデフォルトで「拒否」状態となっており、デフォルトではない「Webアプリ用PAMサービス」は「拒否」となり、GPOベースのアクセス制御自体が許可されていない状態でした。
エラーメッセージは、ユーザではなく、PAMサービスに対する権限エラーだったのですね。

[参考]
Cannot log in to ClearCase WAN server after enabling SSSD-based authentication to Windows Active Directory on Red Hat Linux 8.x.
2.6.3. SSSD の GPO ベースのアクセス制御の設定

■解決方法

解決方法としては以下2パターンが考えられます。
①GPOベースのアクセス制御時、デフォルト以外のPAMサービスの扱いを「拒否」から「許可」に設定変更(=記載追加)する。
以下のとおりsssd.confのADドメインのセクションに記載を追加し、SSSDサービスを再起動することで対応。
/etc/sssd/sssd.conf
ad_gpo_default_right=permit

②GPOベースのアクセス制御時、作成したPAMサービスを「許可」するよう設定追加する。
以下のとおりsssd.confのADドメインのセクションに記載を追加し、SSSDサービスを再起動することで対応。
/etc/sssd/sssd.conf
ad_gpo_map_interactive = + webapp

解決策として①だと影響範囲が大きそうでしたので②を採用しました。

■まとめ

今回、ActiveDirectoryやSSSD設定が自分の担当ではなかったことで原因調査がかなり難航しました。
振り返りとしては、早期にSSSDのデバッグログ出力設定変更などの相談をしておくと良かったのかなとも思いましたが、原因箇所が特定出来ない中では中々難しいですね。

個人的には良い経験となったと思いますので、この内容を今後に活かしていければ良いなと考えています。

2022年5月16日月曜日

Angular ユニットテスト エラー(NgRxのReducerで発生する「TypeError: Cannot read properties of undefined (reading 'xxxxx')」)の対応。

オフィス狛 技術部のKoma(Twitterアカウントの中の人&CEO)です。

前回前々回に引き続き、Angularでユニットテスト作成する時のエラー対応です。
今回はNgRx関連のエラーです。
error properties: Object({ longStack: 'TypeError: Cannot read properties of undefined (reading 'xxxxx')
      at http://localhost:9876/_karma_webpack_/webpack:/src/app/koma/store/reducers/index.ts:xx:xx
みたいなエラーが出てしまった場合の対応です。

・・・・ええ、言いたいことは分かります。
undefinedって出ているんだから、該当箇所見ればすぐ分かるじゃん。
って事ですよね?

まあ、仰る通りなのですが、エラーとなっている箇所は、コンポーネント側ではなく、NgRxのReducer(index.ts)で、エラーも微妙な位置なので、どう修正すれば良いのか、分からないんですよね・・・・。

ちなみに、コンポーネント側は、
[koma-confirm.component.ts]
export class KomaConfirmComponent implements OnInit, OnDestroy {
  komaConfirmSubscription: Subscription = new Subscription();
  saveData: KomaViewSaveModel;

  constructor(
    private router: Router,
    public store: Store<fromKoma.State>
  ) {}

  ngOnInit(): void {
    // 画面間保持項目の退避
    this.komaConfirmSubscription.add(
      this.store
        .pipe(select(fromKoma.getDataRegisterPost))
        .subscribe(model => {
          if (model) {
            this.saveData = model;
          } else {
            this.router.navigateByUrl('/koma/home');
          }
        }),
    );
  }

  ngOnDestroy(): void {
    this.komaConfirmSubscription.unsubscribe();
  }
}
となっています。

良くある登録系機能(入力→確認→完了)の「確認画面」を想定して頂くと分かりやすいです。
入力画面で入力された値をStoreに格納し、それを確認画面(のngOnInit)で取得しようとしています。
もし、Storeからデータを取得出来ない場合、不正な遷移を行ったと見做し、ホーム画面に遷移させています。

なので、
      this.store
        .pipe(select(fromKoma.getDataRegisterPost))
が何となく怪しいのは分かるのですが、Reducer(index.ts)側は、

[/src/app/koma/store/reducers/index.ts]
export const getKomaState = createSelector(
  getKomaFeatureState,
  (state: KomaFeatureState) => state.koma, // ←ここがエラーになってる。
);

export const getDataRegisterPost = createSelector(
  getKomaState,
  fromKoma.getDataRegisterPost, // ←せめてここでエラーになっていれば・・・・
);
上記の「state.koma」の部分がエラーとなっていて、内容としては、
TypeError: Cannot read properties of undefined (reading 'koma')
と出ています。

せめて、
  fromKoma.getDataRegisterPost,
の部分でエラーになっていれば、エラー原因の当たりがすぐにつくのですが・・・・

では、このエラーの対応をしていきたいと思います。

テストファイルの方を見ていきましょう。
[koma-confirm.component.spec.ts]
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { provideMockStore } from '@ngrx/store/testing';
import { KomaConfirmComponent } from './koma-confirm.component';

describe('KomaConfirmComponent', () => {
  let component: KomaConfirmComponent;
  let fixture: ComponentFixture<KomaConfirmComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [KomaConfirmComponent],
      providers: [
        provideMockStore(),
      ],
      imports: [
        RouterTestingModule,
      ],
    }).compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(KomaConfirmComponent);

    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});
DIの設定だけはしている感じですね。

前回は、「StoreのdispatchでActionの実行をテストする」だったので、特に意識しなかったのですが、
Storeからデータを取得する、という場合は、「selector」と、取得するデータを定義する必要があります。

[koma-confirm.component.spec.ts]
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { provideMockStore } from '@ngrx/store/testing';
import * as fromKoma from '../../store/reducers';
import { KomaConfirmComponent } from './koma-confirm.component';

describe('KomaConfirmComponent', () => {
  let component: KomaConfirmComponent;
  let fixture: ComponentFixture<KomaConfirmComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [KomaConfirmComponent],
      providers: [
        provideMockStore({
          selectors: [
            { selector: fromKoma.getDataRegisterPost,
              value: {
                komaId: '1',
                komaData: '1111',
              }
            },
          ]
        }),
      ],
      imports: [
        RouterTestingModule,
      ],
    }).compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(KomaConfirmComponent);

    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});

provideMockStoreの定義に引数として、selectorとvalueを設定しています。

これで、エラー(TypeError: Cannot read properties of undefined (reading 'xxxxx'))は出なくなると思います。

では、ちゃんとテストケースを追加していこうと思います。

まず、書き換えたいのが、
provideMockStoreの定義に引数として、selectorとvalueを設定している部分です。
今、そこを追加したのに・・・変えるんかい!って感じですね。

後々、テストケースが追加になった時に、selector内の値の入れ替えを楽にしたいので、
provideMockStoreの引数で設定するのではなく、テストクラス内で使えるように変えていこうと思います。
テストケースも追加していきます。
[koma-confirm.component.spec.ts]
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { Router } from '@angular/router';
import { provideMockStore, MockStore } from '@ngrx/store/testing';
import * as fromKoma from '../../store/reducers';
import { KomaConfirmComponent } from './koma-confirm.component';
import { KomaViewSaveModel } from '../../models/koma';

describe('KomaConfirmComponent', () => {
  let component: KomaConfirmComponent;
  let fixture: ComponentFixture<KomaConfirmComponent>;
  // 全テストケースで利用出来るように、ここで定義
  let store: MockStore<fromKoma.State>;
  let router: Router;
  let mockDataRegisterPostSelector;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [KomaConfirmComponent],
      providers: [
        provideMockStore(),
      ],
      imports: [
        RouterTestingModule,
      ],
    }).compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(KomaConfirmComponent);

    // DI後のインスタンスを取得
    router = TestBed.inject(Router);
    store = TestBed.inject(MockStore);

    // データの初期値を設定
    mockDataRegisterPostSelector = store.overrideSelector(
      fromAccount.getDataRegisterPost, {
        komaId: '1',
        komaData: '1111'
      }
    );

    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });

  describe('ngOnInitのテスト', () => {
    it('画面引き継ぎ情報が無い場合、ホーム画面へ遷移すること', () => {
      // 処理のモックを作成
      const navigateByUrlSpy = spyOn(router, 'navigateByUrl');

      // データにnullを設定
      mockDataRegisterPostSelector.setResult(null);

      // テスト対象のメソッドを呼び出す
      component.ngOnInit();

      // navigateByUrlが、'/koma/home'を引数として呼ばれているか確認
      expect(navigateByUrlSpy).toHaveBeenCalledWith('/koma/home');
    });

    it('画面引き継ぎ情報がある場合、データを退避すること', () => {
      // 処理のモックを作成
      const navigateByUrlSpy = spyOn(router, 'navigateByUrl');

      // データを再設定
      const formModel: KomaViewSaveModel = {
        komaId: '2',
        komaData: '2222'
      };
      mockDataRegisterPostSelector.setResult(formModel);

      // テスト対象のメソッドを呼び出す
      component.ngOnInit();

      // 退避されたデータの中身をチェック
      expect(component.saveData).not.toBeUndefined();
      expect(component.saveData).not.toBeNull();
      expect(component.saveData.komaId).toBe('2');
      expect(component.saveData.komaData).toBe('2222');

      // navigateByUrlが、呼ばれていないか確認
      expect(navigateByUrlSpy).not.toHaveBeenCalled();
    });
  });
});

ポイントとしては、「overrideSelector」によって、selectorを再定義している部分
    mockDataRegisterPostSelector = store.overrideSelector(
        // (中略)
    );
と、値を再設定する
    mockDataRegisterPostSelector.setResult(formModel);
の部分ですね。

これで、Store内のデータを設定し、テストをすることが可能になりました!

今後も、Angularでユニットテストについて、ブログに書いて行こうと思います。

2022年5月13日金曜日

Angular ユニットテスト エラー( NullInjectorError: No provider for Store )の対応。

オフィス狛 技術部のKoma(Twitterアカウントの中の人&CEO)です。

前回に引き続き、Angularでユニットテスト作成時のエラー対応です。
今回もDI関連のエラーです。
NullInjectorError: R3InjectorError(DynamicTestModule)[Store -> Store]: 
  NullInjectorError: No provider for Store!
上記のエラーが出てしまった場合の対応です。

これは、NgRxのStoreを使用している場合に出てくるエラーとなります。
NgRxの公式サイトを見ると、コンポーネント側のテスト記載のサンプルがあるので、詳細はそちらを見るとして、とりあえず、エラーを無くしてみます。
まずは、コンポーネント側のロジックを確認してみましょう。

例として、よくある「入力画面(から確認画面)」を挙げてみます。
「入力画面で、入力した値をSubmitによって、Storeに保存し、確認画面へ引き継ぐ」って感じですね。
[input.component.ts]
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import * as fromInput from '../../store/reducers';
import * as InputActions from '../../store/actions/input.actions';
import { InputViewSaveModel } from '../../models/input';
// (中略)
  constructor(
    private router: Router,
    public store: Store<fromInput.State>,
  ) {}
// (中略)
  onSubmit(inputData: InputViewSaveModel): void {
    // 入力情報をStoreに格納して(=引き継ぎ情報として)確認画面へ遷移
    this.store.dispatch(InputActions.setRegisterPostData({ data: inputData }));
    this.router.navigateByUrl('/koma/confirm');
  }

ここではNgRxの仕様については触れませんので、NgRxを知っている前提で進めます。
それと、前回やった「Router」もありますので、一緒にエラーが出ないように対応してみます。

まずは必要モジュールのインポートと、DIをします。
(Angularでは、DI可能なクラスであれば、コンポーネントのコンストラクタに定義する事で、DIして使用可能になります。)
[input.component.spec.ts]
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { provideMockStore } from '@ngrx/store/testing';
import { InputComponent } from './input.component';

describe('InputComponent', () => {
  let component: InputComponent;
  let fixture: ComponentFixture<InputComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [InputComponent],
      providers: [provideMockStore()],
      imports: [RouterTestingModule],
    }).compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(InputComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});
と言う感じになります。

Storeに関する部分は、
import { provideMockStore } from '@ngrx/store/testing';
と、
      providers: [provideMockStore()],
を追加しています。
先ほどのエラー(NullInjectorError)を無くすだけであれば、実は上記でOKです。

ただ、当然テストケースは作らないといけないので、ついでにやってみましょう
[input.component.spec.ts]
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { Router } from '@angular/router';
import { provideMockStore, MockStore } from '@ngrx/store/testing';
import * as fromInput from '../../store/reducers';
import * as InputActions from '../../store/actions/input.actions';
import { InputViewSaveModel } from '../../models/input';
import { InputComponent } from './input.component';

describe('InputComponent', () => {
  let component: InputComponent;
  let fixture: ComponentFixture<InputComponent>;
  // 全テストケースで利用出来るように、ここで定義
  let router: Router;
  let store: MockStore<fromInput.State>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [InputComponent],
      providers: [provideMockStore()],
      imports: [RouterTestingModule],
    }).compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(InputComponent);

    // DI後のインスタンスを取得
    router = TestBed.inject(Router);
    store = TestBed.inject(MockStore);

    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });

  describe('ボタンアクションテスト', () => {
    it('確認ボタン押下時 Storeにデータを保存し、確認画面へ遷移する事', () => {
      // 処理のモックを作成
      const dispatchSpy = spyOn(store, 'dispatch');
      const navigateByUrlSpy = spyOn(router, 'navigateByUrl');

      // 画面から入力された値を再現
      const formModel: InputViewSaveModel = {
        id: '111',
        name: 'koma-chan',
        address: 'tokyo',
      };

      // 実行されるアクションを定義(画面からの入力値をStoreへ保存するアクション)
      const expectedAction = InputActions.setRegisterPostData({ data: formModel });

      // テスト対象のメソッドを呼び出す
      component.onSubmit(formModel);

      // Storeのdispatchが、アクションを引数として呼ばれているか確認
      expect(dispatchSpy).toHaveBeenCalledWith(expectedAction);

      // navigateByUrlが、'/koma/confirm'を引数として呼ばれているか確認
      expect(navigateByUrlSpy).toHaveBeenCalledWith('/koma/confirm');
    });
  });
});

ポイントとしては、DIしたクラスをテスト側で使用する為には、
    store = TestBed.inject(MockStore);
が必要、というところです。
実際のテストの部分は、プログラム内のコメントを参照して頂ければと思いますが、流れ的に、

① spyOn で、Store の dispatch に対するモックを作成
② 画面の入力値を Store に保存するアクションを定義
③ コンポーネント側の onSubmit を呼び出す
③ expect の toHaveBeenCalledWith で、指定の引数(アクション)で dispatch が呼ばれたかを判定

となっています。

ちょっと長くなってしまったので、その他のStore関連のテストエラーは別途記事にしようと思います。

2022年5月12日木曜日

position: absolute;を使って重ねた画像にCSSで乗算の影をつける。


 

こんにちは、オフィス狛 デザイン部のSatoです。


先日、並んで表示されている複数の要素の上に、position: absolute;を使って画像を重ねて配置し、配置した画像に影をつけたいことがありました。

しかし、配置した画像は別の色の要素を跨いでいるため、普通の方法ではなかなか自然な影にならず困りました。

そこでCSSのブレンドモードで乗算の影をつけたら自然になるはずと思い試していたのですが、調整に時間がかかってしまいました。

あまり発生しないケースかもしれませんが、今後の自分用の備忘録も兼ねて解決方法を書いていこうかとおもいます。


上手くいかなかった方法


最初、imgをdivで囲みdrop-shadowを指定してあげれば影ができるはずですし、一緒に「mix-blend-mode: multiply;」を指定してみました。予想はできていましたが、影だけではなく画像まで乗算になってしまいました😔


それならば、imgタグに擬似要素「::before」をつけて、画像と同じサイズにした影を作りz-indexを使って画像下になるよう重ねてみようと思いました。……影が出てこないので、調べた所「::before」「::after」はimgには指定できないとのこと。初歩的なミスをやらかしてしまいました😔


それならば…imgを囲んでいるdivに擬似要素「::before」をつけようとしました。

影が画像の上に乗ってしまったのでz-indexを指定すると、乗算でなくなってしまいます😔mix-blend-modeとスタックコンテキスト

↑こちらのサイト曰くmix-blend-modeは同じスタックコンテキストに属している要素にしか効果がないらしいです。
ブレンドモードを使いこなすのは難しいですね…。



上手くいった方法


親要素の中に影用のdivを作り、画像と同じサイズにした影用の要素を画像の下に重なるよう配置した所、ようやく想像した通りの見た目になりました😄

See the Pen position: absolute;で浮かせた画像に乗算の影をつける by sato (@officekoma_sato) on CodePen.

上記のような感じになりました。

どちらの色のエリアでも違和感のない自然な影になり満足です✌️

あまり発生しないケースですし、影色を乗算にしなければ良いのですが、どうしても乗算を使いたいケースがあれば参考にしてみてください。